diff --git a/Cargo.lock b/Cargo.lock index b48cdd9..95b7a76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,6 +168,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "camino" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0" + [[package]] name = "cfg-if" version = "1.0.1" @@ -403,6 +409,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "djls" version = "5.2.0-alpha" @@ -453,10 +470,13 @@ name = "djls-server" version = "0.0.0" dependencies = [ "anyhow", + "camino", + "dashmap", "djls-conf", "djls-dev", "djls-project", "djls-templates", + "djls-workspace", "percent-encoding", "pyo3", "salsa", @@ -467,6 +487,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "url", ] [[package]] @@ -481,6 +502,16 @@ dependencies = [ "toml", ] +[[package]] +name = "djls-workspace" +version = "0.0.0" +dependencies = [ + "camino", + "dashmap", + "salsa", + "url", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -564,6 +595,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.31" @@ -718,6 +758,113 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.10.0" @@ -817,6 +964,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + [[package]] name = "lock_api" version = "0.4.13" @@ -1056,6 +1209,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1465,6 +1627,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "strsim" version = "0.11.1" @@ -1613,6 +1781,16 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.47.1" @@ -1859,6 +2037,23 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2111,6 +2306,12 @@ dependencies = [ "bitflags 2.9.2", ] +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + [[package]] name = "yaml-rust2" version = "0.10.3" @@ -2121,3 +2322,81 @@ dependencies = [ "encoding_rs", "hashlink", ] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 2a674a3..8a88a0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ djls-dev = { path = "crates/djls-dev" } djls-project = { path = "crates/djls-project" } djls-server = { path = "crates/djls-server" } djls-templates = { path = "crates/djls-templates" } +djls-workspace = { path = "crates/djls-workspace" } # core deps, pin exact versions pyo3 = "0.25.0" @@ -17,8 +18,10 @@ salsa = "0.23.0" tower-lsp-server = { version = "0.22.0", features = ["proposed"] } anyhow = "1.0" +camino = "1.1" clap = { version = "4.5", features = ["derive"] } config = { version ="0.15", features = ["toml"] } +dashmap = "6.1" directories = "6.0" percent-encoding = "2.3" serde = { version = "1.0", features = ["derive"] } @@ -29,6 +32,7 @@ toml = "0.9" tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] } +url = "2.5" which = "8.0" # testing diff --git a/crates/djls-server/Cargo.toml b/crates/djls-server/Cargo.toml index 396f0f3..e3f27f8 100644 --- a/crates/djls-server/Cargo.toml +++ b/crates/djls-server/Cargo.toml @@ -11,8 +11,11 @@ default = [] djls-conf = { workspace = true } djls-project = { workspace = true } djls-templates = { workspace = true } +djls-workspace = { workspace = true } anyhow = { workspace = true } +camino = { workspace = true } +dashmap = { workspace = true } percent-encoding = { workspace = true } pyo3 = { workspace = true } salsa = { workspace = true } @@ -23,6 +26,7 @@ tower-lsp-server = { workspace = true } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } +url = { workspace = true } [build-dependencies] djls-dev = { workspace = true } diff --git a/crates/djls-workspace/Cargo.toml b/crates/djls-workspace/Cargo.toml new file mode 100644 index 0000000..9f8edce --- /dev/null +++ b/crates/djls-workspace/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "djls-workspace" +version = "0.0.0" +edition = "2021" + +[dependencies] +camino = { workspace = true } +dashmap = { workspace = true } +salsa = { workspace = true } +url = { workspace = true } + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/djls-workspace/src/bridge.rs b/crates/djls-workspace/src/bridge.rs new file mode 100644 index 0000000..97f894e --- /dev/null +++ b/crates/djls-workspace/src/bridge.rs @@ -0,0 +1,108 @@ +//! Bridge between VFS snapshots and Salsa inputs. +//! +//! The bridge module isolates Salsa input mutation behind a single, idempotent API. +//! It ensures we only touch Salsa when content or classification changes, maximizing +//! incremental performance. + +use std::{collections::HashMap, sync::Arc}; + +use salsa::Setter; + +use super::{ + db::{Database, FileKindMini, SourceFile, TemplateLoaderOrder}, + vfs::{FileKind, VfsSnapshot}, + FileId, +}; + +/// Owner of the Salsa [`Database`] plus the handles for updating inputs. +/// +/// [`FileStore`] serves as the bridge between the VFS (with [`FileId`]s) and Salsa (with entities). +/// It maintains a mapping from [`FileId`]s to [`SourceFile`] entities and manages the global +/// [`TemplateLoaderOrder`] input. The [`FileStore`] ensures that Salsa inputs are only mutated +/// when actual changes occur, preserving incremental computation efficiency. +pub struct FileStore { + /// The Salsa DB instance + pub db: Database, + /// Map from [`FileId`] to its Salsa input entity + files: HashMap, + /// Handle to the global template loader configuration input + template_loader: Option, +} + +impl FileStore { + /// Construct an empty store and DB. + pub fn new() -> Self { + Self { + db: Database::default(), + files: HashMap::new(), + template_loader: None, + } + } + + /// Create or update the global template loader order input. + /// + /// Sets the ordered list of template root directories that Django will search + /// when resolving template names. If the input already exists, it updates the + /// existing value; otherwise, it creates a new [`TemplateLoaderOrder`] input. + pub fn set_template_loader_order(&mut self, ordered_roots: Vec) { + let roots = Arc::from(ordered_roots.into_boxed_slice()); + if let Some(tl) = self.template_loader { + tl.set_roots(&mut self.db).to(roots); + } else { + self.template_loader = Some(TemplateLoaderOrder::new(&self.db, roots)); + } + } + + /// 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 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 = match rec.meta.kind { + FileKind::Python => FileKindMini::Python, + FileKind::Template => FileKindMini::Template, + FileKind::Other => FileKindMini::Other, + }; + + 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.clone()); + } + 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`]. + /// + /// Returns `None` if the file is not tracked in the [`FileStore`]. + pub fn file_text(&self, id: FileId) -> Option> { + self.files.get(&id).map(|sf| sf.text(&self.db).clone()) + } + + /// Get the file kind classification by its [`FileId`]. + /// + /// Returns `None` if the file is not tracked in the [`FileStore`]. + pub fn file_kind(&self, id: FileId) -> Option { + self.files.get(&id).map(|sf| sf.kind(&self.db)) + } +} + +impl Default for FileStore { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/djls-workspace/src/db.rs b/crates/djls-workspace/src/db.rs new file mode 100644 index 0000000..91aa1cc --- /dev/null +++ b/crates/djls-workspace/src/db.rs @@ -0,0 +1,91 @@ +//! Salsa database and input entities for workspace. +//! +//! This module defines the Salsa world—what can be set and tracked incrementally. +//! Inputs are kept minimal to avoid unnecessary recomputation. + +use std::sync::Arc; +#[cfg(test)] +use std::sync::Mutex; + +/// 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. +#[salsa::db] +#[derive(Clone)] +#[cfg_attr(not(test), derive(Default))] +pub struct Database { + storage: salsa::Storage, + + // The logs are only used for testing and demonstrating reuse: + #[cfg(test)] + logs: Arc>>>, +} + +#[cfg(test)] +impl Default for Database { + fn default() -> Self { + let logs = >>>>::default(); + Self { + storage: salsa::Storage::new(Some(Box::new({ + let logs = logs.clone(); + move |event| { + eprintln!("Event: {event:?}"); + // Log interesting events, if logging is enabled + if let Some(logs) = &mut *logs.lock().unwrap() { + // only log interesting events + if let salsa::EventKind::WillExecute { .. } = event.kind { + logs.push(format!("Event: {event:?}")); + } + } + } + }))), + logs, + } + } +} + +#[salsa::db] +impl salsa::Database for Database {} + +/// Minimal classification for analysis routing. +/// +/// [`FileKindMini`] provides a lightweight categorization of files to determine which +/// analysis pipelines should process them. This is the Salsa-side representation +/// of file types, mapped from the VFS layer's `vfs::FileKind`. +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub enum FileKindMini { + /// Python source file (.py) + Python, + /// Django template file (.html, .jinja, etc.) + Template, + /// Other file types not requiring specialized analysis + Other, +} + +/// Represents a single file's classification and current content. +/// +/// [`SourceFile`] is a Salsa input entity that tracks both the file's type (for routing +/// to appropriate analyzers) and its current text content. The text is stored as +/// `Arc` for efficient sharing across the incremental computation graph. +#[salsa::input] +pub struct SourceFile { + /// The file's classification for analysis routing + pub kind: FileKindMini, + /// The current text content of the file + #[returns(ref)] + pub text: Arc, +} + +/// Global input configuring ordered template loader roots. +/// +/// [`TemplateLoaderOrder`] represents the Django `TEMPLATES[n]['DIRS']` configuration, +/// defining the search order for template resolution. This is a global input that +/// affects template name resolution across the entire project. +#[salsa::input] +pub struct TemplateLoaderOrder { + /// Ordered list of template root directories + #[returns(ref)] + pub roots: Arc<[String]>, +} diff --git a/crates/djls-workspace/src/lib.rs b/crates/djls-workspace/src/lib.rs new file mode 100644 index 0000000..aea4a53 --- /dev/null +++ b/crates/djls-workspace/src/lib.rs @@ -0,0 +1,30 @@ +mod bridge; +mod db; +mod vfs; + +// Re-export public API +pub use bridge::FileStore; +pub use db::{Database, FileKindMini, SourceFile, TemplateLoaderOrder}; +pub use vfs::{FileKind, FileMeta, FileRecord, Revision, TextSource, Vfs, VfsSnapshot}; + +/// 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. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub struct FileId(u32); + +impl FileId { + /// Create a [`FileId`] from a raw u32 value. + #[must_use] + pub fn from_raw(raw: u32) -> Self { + FileId(raw) + } + + /// Get the underlying u32 index value. + #[must_use] + pub fn index(self) -> u32 { + self.0 + } +} diff --git a/crates/djls-workspace/src/vfs.rs b/crates/djls-workspace/src/vfs.rs new file mode 100644 index 0000000..65f4008 --- /dev/null +++ b/crates/djls-workspace/src/vfs.rs @@ -0,0 +1,245 @@ +//! 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. + +use camino::Utf8PathBuf; +use dashmap::DashMap; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU32, AtomicU64, Ordering}, + Arc, + }, +}; +use url::Url; + +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 struct Revision(u64); + +impl Revision { + /// Create a [`Revision`] from a raw u64 value. + pub fn from_raw(raw: u64) -> Self { + Revision(raw) + } + + /// Get the underlying u64 value. + pub 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, classification, and optional version number +/// from the LSP client. +#[derive(Clone, Debug)] +pub struct FileMeta { + /// The file's URI (typically file:// scheme) + pub uri: Url, + /// The file's path in the filesystem + pub path: Utf8PathBuf, + /// Classification for routing to analyzers + pub kind: FileKind, + /// Optional LSP document version + pub version: Option, +} + +/// 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 enum TextSource { + /// Content loaded from disk + Disk(Arc), + /// Content from LSP client overlay (in-memory edits) + Overlay(Arc), + /// Content generated programmatically + Generated(Arc), +} + +/// Complete record of a file in the VFS. +/// +/// [`FileRecord`] combines metadata, current text content, and a content hash +/// for efficient change detection. +#[derive(Clone)] +pub struct FileRecord { + /// File metadata (URI, path, kind, version) + pub meta: FileMeta, + /// Current text content and its source + pub text: TextSource, + /// Hash of current content for change detection + pub hash: u64, +} + +/// 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, +} + +impl Vfs { + /// Construct an empty VFS. + pub fn new() -> Self { + Self { + next_file_id: AtomicU32::new(0), + by_uri: DashMap::new(), + files: DashMap::new(), + head: AtomicU64::new(0), + } + } + + /// 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 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, + kind, + version: None, + }; + let hash = content_hash(&text); + self.by_uri.insert(uri, 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 fn set_overlay( + &self, + id: FileId, + version: Option, + new_text: Arc, + ) -> (Revision, bool) { + let mut rec = self.files.get_mut(&id).expect("unknown file"); + rec.meta.version = version; + let next = TextSource::Overlay(new_text); + let new_hash = content_hash(&next); + let changed = new_hash != rec.hash; + if changed { + rec.text = next; + rec.hash = new_hash; + self.head.fetch_add(1, Ordering::SeqCst); + } + ( + 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 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(), + } + } +} + +/// Compute a stable hash over file content. +/// +/// Used for efficient change detection - if the hash hasn't changed, +/// the content hasn't changed, avoiding unnecessary Salsa invalidations. +fn content_hash(src: &TextSource) -> u64 { + let s: &str = match src { + TextSource::Disk(s) | TextSource::Overlay(s) | TextSource::Generated(s) => s, + }; + let mut h = DefaultHasher::new(); + s.hash(&mut h); + h.finish() +} + +/// Immutable snapshot view of the VFS at a specific revision. +/// +/// [`VfsSnapshot`] provides a consistent view of all files for downstream consumers, +/// avoiding the need for locking during processing. Snapshots are created atomically +/// and can be safely shared across threads. +#[derive(Clone)] +pub struct VfsSnapshot { + /// The global revision at the time of snapshot + pub 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. + 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. + pub fn meta(&self, id: FileId) -> Option<&FileMeta> { + self.files.get(&id).map(|r| &r.meta) + } +}