workspace

This commit is contained in:
Josh Thomas 2025-08-24 19:43:51 -05:00
parent 5749b7df98
commit 75385e0254
8 changed files with 776 additions and 0 deletions

279
Cargo.lock generated
View file

@ -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",
]

View file

@ -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

View file

@ -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 }

View file

@ -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

View file

@ -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<FileId, SourceFile>,
/// Handle to the global template loader configuration input
template_loader: Option<TemplateLoaderOrder>,
}
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<String>) {
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::<str>::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<Arc<str>> {
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<FileKindMini> {
self.files.get(&id).map(|sf| sf.kind(&self.db))
}
}
impl Default for FileStore {
fn default() -> Self {
Self::new()
}
}

View file

@ -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<Self>,
// The logs are only used for testing and demonstrating reuse:
#[cfg(test)]
logs: Arc<Mutex<Option<Vec<String>>>>,
}
#[cfg(test)]
impl Default for Database {
fn default() -> Self {
let logs = <Arc<Mutex<Option<Vec<String>>>>>::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<str>` 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<str>,
}
/// 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]>,
}

View file

@ -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
}
}

View file

@ -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<i64>,
}
/// 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<str>` for efficient sharing.
#[derive(Clone)]
pub enum TextSource {
/// Content loaded from disk
Disk(Arc<str>),
/// Content from LSP client overlay (in-memory edits)
Overlay(Arc<str>),
/// Content generated programmatically
Generated(Arc<str>),
}
/// 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<Url, FileId>,
/// Map from [`FileId`] to [`FileRecord`] for content storage
files: DashMap<FileId, FileRecord>,
/// 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<i64>,
new_text: Arc<str>,
) -> (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<FileId, FileRecord>,
}
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<Arc<str>> {
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)
}
}