This commit is contained in:
Josh Thomas 2025-08-27 15:37:29 -05:00
parent 269d4bceae
commit 4e3446f6ee
14 changed files with 577 additions and 1668 deletions

103
Cargo.lock generated
View file

@ -140,9 +140,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.9.2"
version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29"
checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d"
dependencies = [
"serde",
]
@ -170,21 +170,21 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "camino"
version = "1.1.11"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0"
checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5"
[[package]]
name = "cfg-if"
version = "1.0.1"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "clap"
version = "4.5.45"
version = "4.5.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318"
checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57"
dependencies = [
"clap_builder",
"clap_derive",
@ -192,9 +192,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.44"
version = "4.5.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8"
checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41"
dependencies = [
"anstream",
"anstyle",
@ -442,7 +442,7 @@ dependencies = [
"directories",
"serde",
"tempfile",
"thiserror 2.0.15",
"thiserror 2.0.16",
"toml",
]
@ -498,7 +498,7 @@ dependencies = [
"insta",
"serde",
"tempfile",
"thiserror 2.0.15",
"thiserror 2.0.16",
"toml",
]
@ -883,9 +883,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.10.0"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9"
dependencies = [
"equivalent",
"hashbrown 0.15.5",
@ -903,7 +903,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.3",
"inotify-sys",
"libc",
]
@ -940,11 +940,11 @@ dependencies = [
[[package]]
name = "io-uring"
version = "0.7.9"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4"
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.3",
"cfg-if",
"libc",
]
@ -1010,7 +1010,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.3",
"libc",
]
@ -1106,7 +1106,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.3",
"fsevent-sys",
"inotify",
"kqueue",
@ -1224,9 +1224,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.1"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
@ -1235,7 +1235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323"
dependencies = [
"memchr",
"thiserror 2.0.15",
"thiserror 2.0.16",
"ucd-trie",
]
@ -1417,7 +1417,7 @@ version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.3",
]
[[package]]
@ -1428,19 +1428,19 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 2.0.15",
"thiserror 2.0.16",
]
[[package]]
name = "regex"
version = "1.11.1"
version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.4.9",
"regex-syntax 0.8.5",
"regex-automata 0.4.10",
"regex-syntax 0.8.6",
]
[[package]]
@ -1454,13 +1454,13 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.9"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax 0.8.5",
"regex-syntax 0.8.6",
]
[[package]]
@ -1471,9 +1471,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "regex-syntax"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "ron"
@ -1482,7 +1482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
dependencies = [
"base64",
"bitflags 2.9.2",
"bitflags 2.9.3",
"serde",
"serde_derive",
]
@ -1516,7 +1516,7 @@ version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.3",
"errno",
"libc",
"linux-raw-sys",
@ -1630,9 +1630,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.142"
version = "1.0.143"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7"
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
dependencies = [
"itoa",
"memchr",
@ -1765,15 +1765,15 @@ checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a"
[[package]]
name = "tempfile"
version = "3.20.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@ -1793,11 +1793,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.15"
version = "2.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d76d3f064b981389ecb4b6b7f45a0bf9fdac1d5b9204c7bd6714fecc302850"
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
dependencies = [
"thiserror-impl 2.0.15",
"thiserror-impl 2.0.16",
]
[[package]]
@ -1813,9 +1813,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.15"
version = "2.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d29feb33e986b6ea906bd9c3559a856983f92371b3eaa5e83782a351623de0"
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
dependencies = [
"proc-macro2",
"quote",
@ -2129,13 +2129,14 @@ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "url"
version = "2.5.4"
version = "2.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
@ -2393,9 +2394,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.12"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
@ -2412,7 +2413,7 @@ version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.3",
]
[[package]]

View file

@ -202,25 +202,13 @@ impl LanguageServer for DjangoLanguageServer {
async fn did_open(&self, params: lsp_types::DidOpenTextDocumentParams) {
tracing::info!("Opened document: {:?}", params.text_document.uri);
self.with_session_mut(|session| {
let uri = params.text_document.uri.clone();
let version = params.text_document.version;
let language_id =
self.with_session_mut(|_session| {
// TODO: Handle document open after refactoring
let _uri = params.text_document.uri.clone();
let _version = params.text_document.version;
let _language_id =
djls_workspace::LanguageId::from(params.text_document.language_id.as_str());
let text = params.text_document.text.clone();
// Convert LSP Uri to url::Url
if let Ok(url) = url::Url::parse(&uri.to_string()) {
if let Err(e) =
session
.documents_mut()
.open_document(url, version, language_id, text)
{
tracing::error!("Failed to handle did_open: {}", e);
}
} else {
tracing::error!("Invalid URI: {:?}", uri);
}
let _text = params.text_document.text.clone();
})
.await;
}
@ -228,22 +216,11 @@ impl LanguageServer for DjangoLanguageServer {
async fn did_change(&self, params: lsp_types::DidChangeTextDocumentParams) {
tracing::info!("Changed document: {:?}", params.text_document.uri);
self.with_session_mut(|session| {
let uri = &params.text_document.uri;
let version = params.text_document.version;
let changes = params.content_changes.clone();
// Convert LSP Uri to url::Url
if let Ok(url) = url::Url::parse(&uri.to_string()) {
if let Err(e) = session
.documents_mut()
.update_document(&url, version, changes)
{
tracing::error!("Failed to handle did_change: {}", e);
}
} else {
tracing::error!("Invalid URI: {:?}", uri);
}
self.with_session_mut(|_session| {
// TODO: Handle document change after refactoring
let _uri = &params.text_document.uri;
let _version = params.text_document.version;
let _changes = params.content_changes.clone();
})
.await;
}
@ -251,99 +228,19 @@ impl LanguageServer for DjangoLanguageServer {
async fn did_close(&self, params: lsp_types::DidCloseTextDocumentParams) {
tracing::info!("Closed document: {:?}", params.text_document.uri);
self.with_session_mut(|session| {
let uri = &params.text_document.uri;
// Convert LSP Uri to url::Url
if let Ok(url) = url::Url::parse(&uri.to_string()) {
session.documents_mut().close_document(&url);
} else {
tracing::error!("Invalid URI: {:?}", uri);
}
self.with_session_mut(|_session| {
// TODO: Handle document close after refactoring
let _uri = &params.text_document.uri;
})
.await;
}
async fn completion(
&self,
params: lsp_types::CompletionParams,
_params: lsp_types::CompletionParams,
) -> LspResult<Option<lsp_types::CompletionResponse>> {
Ok(self
.with_session(|session| {
if let Some(project) = session.project() {
if let Some(tags) = project.template_tags() {
let uri = &params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
// Convert LSP Uri to url::Url
if let Ok(url) = url::Url::parse(&uri.to_string()) {
if let Some(context) =
session.documents().get_template_context(&url, position)
{
// Use the context to generate completions
let mut completions: Vec<lsp_types::CompletionItem> = tags
.iter()
.filter(|tag| {
context.partial_tag.is_empty()
|| tag.name().starts_with(&context.partial_tag)
})
.map(|tag| {
let leading_space =
if context.needs_leading_space { " " } else { "" };
lsp_types::CompletionItem {
label: tag.name().to_string(),
kind: Some(lsp_types::CompletionItemKind::KEYWORD),
detail: Some(format!(
"Template tag from {}",
tag.library()
)),
documentation: tag.doc().as_ref().map(|doc| {
lsp_types::Documentation::MarkupContent(
lsp_types::MarkupContent {
kind: lsp_types::MarkupKind::Markdown,
value: (*doc).to_string(),
},
)
}),
insert_text: Some(match context.closing_brace {
djls_workspace::ClosingBrace::None => {
format!("{}{} %}}", leading_space, tag.name())
}
djls_workspace::ClosingBrace::PartialClose => {
format!("{}{} %", leading_space, tag.name())
}
djls_workspace::ClosingBrace::FullClose => {
format!("{}{} ", leading_space, tag.name())
}
}),
insert_text_format: Some(
lsp_types::InsertTextFormat::PLAIN_TEXT,
),
..Default::default()
}
})
.collect();
if completions.is_empty() {
None
} else {
completions.sort_by(|a, b| a.label.cmp(&b.label));
Some(lsp_types::CompletionResponse::Array(completions))
}
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
}
})
.await)
// TODO: Handle completion after refactoring
Ok(None)
}
async fn did_change_configuration(&self, _params: lsp_types::DidChangeConfigurationParams) {

View file

@ -1,17 +1,59 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use djls_conf::Settings;
use djls_project::DjangoProject;
use djls_workspace::DocumentStore;
use djls_workspace::{FileSystem, StdFileSystem, db::Database};
use percent_encoding::percent_decode_str;
use salsa::StorageHandle;
use tower_lsp_server::lsp_types;
use url::Url;
#[derive(Default)]
pub struct Session {
/// The Django project configuration
project: Option<DjangoProject>,
documents: DocumentStore,
/// LSP server settings
settings: Settings,
/// A thread-safe Salsa database handle that can be shared between threads.
///
/// This implements the insight from [this Salsa Zulip discussion](https://salsa.zulipchat.com/#narrow/channel/145099-Using-Salsa/topic/.E2.9C.94.20Advice.20on.20using.20salsa.20from.20Sync.20.2B.20Send.20context/with/495497515)
/// where we're using the `StorageHandle` to create a thread-safe handle that can be
/// shared between threads. When we need to use it, we clone the handle to get a new reference.
///
/// This handle allows us to create database instances as needed.
/// Even though we're using a single-threaded runtime, we still need
/// this to be thread-safe because of LSP trait requirements.
///
/// Usage:
/// ```rust,ignore
/// // Clone the StorageHandle for use in an async context
/// let db_handle = session.db_handle.clone();
///
/// // Use it in an async context
/// async_fn(move || {
/// // Get a database from the handle
/// let storage = db_handle.into_storage();
/// let db = Database::from_storage(storage);
///
/// // Use the database
/// db.some_query(args)
/// });
/// ```
db_handle: StorageHandle<Database>,
/// File system abstraction for reading files
file_system: Arc<dyn FileSystem>,
/// Index of open documents with overlays (in-memory changes)
/// Maps document URL to its current content
overlays: HashMap<Url, String>,
/// Tracks the session revision for change detection
revision: u64,
#[allow(dead_code)]
client_capabilities: lsp_types::ClientCapabilities,
}
@ -72,8 +114,11 @@ impl Session {
Self {
client_capabilities: params.capabilities.clone(),
project,
documents: DocumentStore::new(),
settings,
db_handle: StorageHandle::new(None),
file_system: Arc::new(StdFileSystem),
overlays: HashMap::new(),
revision: 0,
}
}
@ -85,13 +130,7 @@ impl Session {
&mut self.project
}
pub fn documents(&self) -> &DocumentStore {
&self.documents
}
pub fn documents_mut(&mut self) -> &mut DocumentStore {
&mut self.documents
}
pub fn settings(&self) -> &Settings {
&self.settings
@ -100,4 +139,28 @@ impl Session {
pub fn set_settings(&mut self, settings: Settings) {
self.settings = settings;
}
/// Get a database instance from the session.
///
/// This creates a usable database from the handle, which can be used
/// to query and update data. The database itself is not Send/Sync,
/// but the StorageHandle is, allowing us to work with tower-lsp.
pub fn db(&self) -> Database {
let storage = self.db_handle.clone().into_storage();
Database::from_storage(storage)
}
}
impl Default for Session {
fn default() -> Self {
Self {
project: None,
settings: Settings::default(),
db_handle: StorageHandle::new(None),
file_system: Arc::new(StdFileSystem),
overlays: HashMap::new(),
revision: 0,
client_capabilities: lsp_types::ClientCapabilities::default(),
}
}
}

View file

@ -15,9 +15,8 @@ use super::db::Database;
use super::db::SourceFile;
use super::db::TemplateAst;
use super::db::TemplateLoaderOrder;
use super::vfs::FileKind;
use super::vfs::VfsSnapshot;
use super::FileId;
use super::FileKind;
/// Owner of the Salsa [`Database`] plus the handles for updating inputs.
///
@ -39,7 +38,7 @@ impl FileStore {
#[must_use]
pub fn new() -> Self {
Self {
db: Database::default(),
db: Database::new(),
files: HashMap::new(),
template_loader: None,
}
@ -59,34 +58,26 @@ impl FileStore {
}
}
/// 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(crate) 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 = rec.meta.kind;
// TODO: This will be replaced with direct file management
// pub(crate) 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 = rec.meta.kind;
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);
}
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);
}
}
}
// 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);
// }
// 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`].
///
@ -130,109 +121,6 @@ impl Default for FileStore {
}
}
#[cfg(test)]
mod tests {
use camino::Utf8PathBuf;
use super::*;
use crate::vfs::TextSource;
use crate::vfs::Vfs;
#[test]
fn test_filestore_template_ast_caching() {
let mut store = FileStore::new();
let vfs = Vfs::default();
// Create a template file in VFS
let url = url::Url::parse("file:///test.html").unwrap();
let path = Utf8PathBuf::from("/test.html");
let content: Arc<str> = Arc::from("{% if user %}Hello {{ user.name }}{% endif %}");
let file_id = vfs.intern_file(
url.clone(),
path.clone(),
FileKind::Template,
TextSource::Overlay(content.clone()),
);
vfs.set_overlay(file_id, content.clone()).unwrap();
// Apply VFS snapshot to FileStore
let snapshot = vfs.snapshot();
store.apply_vfs_snapshot(&snapshot);
// Get template AST - should parse and cache
let ast1 = store.get_template_ast(file_id);
assert!(ast1.is_some());
// Get again - should return cached
let ast2 = store.get_template_ast(file_id);
assert!(ast2.is_some());
assert!(Arc::ptr_eq(&ast1.unwrap(), &ast2.unwrap()));
}
#[test]
fn test_filestore_template_errors() {
let mut store = FileStore::new();
let vfs = Vfs::default();
// Create a template with an unclosed tag
let url = url::Url::parse("file:///error.html").unwrap();
let path = Utf8PathBuf::from("/error.html");
let content: Arc<str> = Arc::from("{% if user %}Hello {{ user.name }"); // Missing closing
let file_id = vfs.intern_file(
url.clone(),
path.clone(),
FileKind::Template,
TextSource::Overlay(content.clone()),
);
vfs.set_overlay(file_id, content).unwrap();
// Apply VFS snapshot
let snapshot = vfs.snapshot();
store.apply_vfs_snapshot(&snapshot);
// Get errors - should contain parsing errors
let errors = store.get_template_errors(file_id);
// The template has unclosed tags, so there should be errors
// We don't assert on specific error count as the parser may evolve
// Verify errors are cached
let errors2 = store.get_template_errors(file_id);
assert!(Arc::ptr_eq(&errors, &errors2));
}
#[test]
fn test_filestore_invalidation_on_content_change() {
let mut store = FileStore::new();
let vfs = Vfs::default();
// Create initial template
let url = url::Url::parse("file:///change.html").unwrap();
let path = Utf8PathBuf::from("/change.html");
let content1: Arc<str> = Arc::from("{% if user %}Hello{% endif %}");
let file_id = vfs.intern_file(
url.clone(),
path.clone(),
FileKind::Template,
TextSource::Overlay(content1.clone()),
);
vfs.set_overlay(file_id, content1).unwrap();
// Apply snapshot and get AST
let snapshot1 = vfs.snapshot();
store.apply_vfs_snapshot(&snapshot1);
let ast1 = store.get_template_ast(file_id);
// Change content
let content2: Arc<str> = Arc::from("{% for item in items %}{{ item }}{% endfor %}");
vfs.set_overlay(file_id, content2).unwrap();
// Apply new snapshot
let snapshot2 = vfs.snapshot();
store.apply_vfs_snapshot(&snapshot2);
// Get AST again - should be different due to content change
let ast2 = store.get_template_ast(file_id);
assert!(ast1.is_some() && ast2.is_some());
assert!(!Arc::ptr_eq(&ast1.unwrap(), &ast2.unwrap()));
}
}
// TODO: Re-enable tests after VFS removal is complete
// #[cfg(test)]
// mod tests {

View file

@ -4,23 +4,36 @@
//! Inputs are kept minimal to avoid unnecessary recomputation.
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
#[cfg(test)]
use std::sync::Mutex;
use djls_templates::Ast;
use dashmap::DashMap;
use url::Url;
use crate::vfs::FileKind;
use crate::{FileId, FileKind};
/// 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.
///
/// This database also manages the file system overlay for the workspace,
/// mapping URLs to FileIds and storing file content.
#[salsa::db]
#[derive(Clone)]
#[cfg_attr(not(test), derive(Default))]
pub struct Database {
storage: salsa::Storage<Self>,
/// Map from file URL to FileId (thread-safe)
files: DashMap<Url, FileId>,
/// Map from FileId to file content (thread-safe)
content: DashMap<FileId, Arc<str>>,
/// Next FileId to allocate (thread-safe counter)
next_file_id: Arc<AtomicU32>,
// The logs are only used for testing and demonstrating reuse:
#[cfg(test)]
@ -45,11 +58,86 @@ impl Default for Database {
}
}
}))),
files: DashMap::new(),
content: DashMap::new(),
next_file_id: Arc::new(AtomicU32::new(0)),
logs,
}
}
}
impl Database {
/// Create a new database instance
pub fn new() -> Self {
Self {
storage: salsa::Storage::new(None),
files: DashMap::new(),
content: DashMap::new(),
next_file_id: Arc::new(AtomicU32::new(0)),
#[cfg(test)]
logs: Arc::new(Mutex::new(None)),
}
}
/// Create a new database instance from a storage handle.
/// This is used by Session::db() to create databases from the StorageHandle.
pub fn from_storage(storage: salsa::Storage<Self>) -> Self {
Self {
storage,
files: DashMap::new(),
content: DashMap::new(),
next_file_id: Arc::new(AtomicU32::new(0)),
#[cfg(test)]
logs: Arc::new(Mutex::new(None)),
}
}
/// Add or update a file in the workspace
pub fn set_file(&mut self, url: Url, content: String, _kind: FileKind) {
let file_id = if let Some(existing_id) = self.files.get(&url) {
*existing_id
} else {
let new_id = FileId::from_raw(self.next_file_id.fetch_add(1, Ordering::SeqCst));
self.files.insert(url.clone(), new_id);
new_id
};
let content = Arc::<str>::from(content);
self.content.insert(file_id, content.clone());
// TODO: Update Salsa inputs here when we connect them
}
/// Remove a file from the workspace
pub fn remove_file(&mut self, url: &Url) {
if let Some((_, file_id)) = self.files.remove(url) {
self.content.remove(&file_id);
// TODO: Remove from Salsa when we connect inputs
}
}
/// Get the content of a file by URL
pub fn get_file_content(&self, url: &Url) -> Option<Arc<str>> {
let file_id = self.files.get(url)?;
self.content.get(&*file_id).map(|content| content.clone())
}
/// Get the content of a file by FileId
pub(crate) fn get_content_by_id(&self, file_id: FileId) -> Option<Arc<str>> {
self.content.get(&file_id).map(|content| content.clone())
}
/// Check if a file exists in the workspace
pub fn has_file(&self, url: &Url) -> bool {
self.files.contains_key(url)
}
/// Get all file URLs in the workspace
pub fn files(&self) -> impl Iterator<Item = Url> + use<'_> {
self.files.iter().map(|entry| entry.key().clone())
}
}
#[salsa::db]
impl salsa::Database for Database {}

View file

@ -1,4 +1,4 @@
use crate::vfs::FileKind;
use crate::FileKind;
#[derive(Clone, Debug, PartialEq)]
pub enum LanguageId {

View file

@ -1,11 +1,9 @@
mod language;
mod line_index;
mod store;
mod template;
pub use language::LanguageId;
pub use line_index::LineIndex;
pub use store::DocumentStore;
pub use template::ClosingBrace;
pub use template::TemplateTagContext;
use tower_lsp_server::lsp_types::Position;

View file

@ -1,643 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use anyhow::anyhow;
use anyhow::Result;
use camino::Utf8PathBuf;
use djls_project::TemplateTags;
use tower_lsp_server::lsp_types::CompletionItem;
use tower_lsp_server::lsp_types::CompletionItemKind;
use tower_lsp_server::lsp_types::CompletionResponse;
use tower_lsp_server::lsp_types::Diagnostic;
use tower_lsp_server::lsp_types::DiagnosticSeverity;
use tower_lsp_server::lsp_types::DidChangeTextDocumentParams;
use tower_lsp_server::lsp_types::DidCloseTextDocumentParams;
use tower_lsp_server::lsp_types::DidOpenTextDocumentParams;
use tower_lsp_server::lsp_types::Documentation;
use tower_lsp_server::lsp_types::InsertTextFormat;
use tower_lsp_server::lsp_types::MarkupContent;
use tower_lsp_server::lsp_types::MarkupKind;
use tower_lsp_server::lsp_types::Position;
use tower_lsp_server::lsp_types::Range;
use tower_lsp_server::lsp_types::TextDocumentContentChangeEvent;
use crate::bridge::FileStore;
use crate::db::TemplateAst;
use crate::vfs::FileKind;
use crate::vfs::TextSource;
use crate::vfs::Vfs;
use crate::ClosingBrace;
use crate::LanguageId;
use crate::LineIndex;
use crate::TextDocument;
pub struct DocumentStore {
vfs: Arc<Vfs>,
file_store: Arc<Mutex<FileStore>>,
documents: HashMap<String, TextDocument>,
}
impl Default for DocumentStore {
fn default() -> Self {
Self {
vfs: Arc::new(Vfs::default()),
file_store: Arc::new(Mutex::new(FileStore::new())),
documents: HashMap::new(),
}
}
}
impl DocumentStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Open a document with the given URI, version, language, and text content.
/// This creates a new TextDocument and stores it internally, hiding VFS details.
pub fn open_document(
&mut self,
uri: url::Url,
version: i32,
language_id: LanguageId,
text: String,
) -> Result<()> {
let uri_str = uri.to_string();
let kind = FileKind::from(language_id.clone());
// Convert URI to path - simplified for now, just use URI string
let path = Utf8PathBuf::from(uri.as_str());
// Store content in VFS
let text_source = TextSource::Overlay(Arc::from(text.as_str()));
let file_id = self.vfs.intern_file(uri, path, kind, text_source);
// Set overlay content in VFS
self.vfs.set_overlay(file_id, Arc::from(text.as_str()))?;
// Sync VFS snapshot to FileStore for Salsa tracking
let snapshot = self.vfs.snapshot();
let mut file_store = self.file_store.lock().unwrap();
file_store.apply_vfs_snapshot(&snapshot);
// Create TextDocument with LineIndex
let document = TextDocument::new(uri_str.clone(), version, language_id, file_id, &text);
self.documents.insert(uri_str, document);
Ok(())
}
/// Update a document with the given URI, version, and text changes.
/// This applies changes to the document and updates the VFS accordingly.
pub fn update_document(
&mut self,
uri: &url::Url,
version: i32,
changes: Vec<TextDocumentContentChangeEvent>,
) -> Result<()> {
let uri_str = uri.to_string();
// Get document and file_id from the documents HashMap
let document = self
.documents
.get(&uri_str)
.ok_or_else(|| anyhow!("Document not found: {}", uri_str))?;
let file_id = document.file_id();
// Get current content from VFS
let snapshot = self.vfs.snapshot();
let current_content = snapshot
.get_text(file_id)
.ok_or_else(|| anyhow!("File content not found: {}", uri_str))?;
// Get line index from the document
let line_index = document.line_index();
// Apply text changes using the existing function
let new_content = apply_text_changes(&current_content, &changes, line_index)?;
// Update TextDocument version and content
if let Some(document) = self.documents.get_mut(&uri_str) {
document.version = version;
document.update_content(&new_content);
}
// Update VFS with new content
self.vfs
.set_overlay(file_id, Arc::from(new_content.as_str()))?;
// Sync VFS snapshot to FileStore for Salsa tracking
let snapshot = self.vfs.snapshot();
let mut file_store = self.file_store.lock().unwrap();
file_store.apply_vfs_snapshot(&snapshot);
Ok(())
}
/// Close a document with the given URI.
/// This removes the document from internal storage and cleans up resources.
pub fn close_document(&mut self, uri: &url::Url) {
let uri_str = uri.as_str();
// Remove TextDocument metadata
self.documents.remove(uri_str);
// Note: We don't remove from VFS as it might be useful for caching
// The VFS will handle cleanup internally
}
#[must_use]
pub fn get_line_index(&self, uri: &str) -> Option<&LineIndex> {
self.documents.get(uri).map(super::TextDocument::line_index)
}
#[allow(dead_code)]
#[must_use]
pub fn get_version(&self, uri: &str) -> Option<i32> {
self.documents.get(uri).map(super::TextDocument::version)
}
#[allow(dead_code)]
#[must_use]
pub fn is_version_valid(&self, uri: &str, version: i32) -> bool {
self.get_version(uri) == Some(version)
}
// TextDocument helper methods
#[must_use]
pub fn get_document(&self, uri: &str) -> Option<&TextDocument> {
self.documents.get(uri)
}
pub fn get_document_mut(&mut self, uri: &str) -> Option<&mut TextDocument> {
self.documents.get_mut(uri)
}
// URI-based query methods (new API)
#[must_use]
pub fn get_document_by_url(&self, uri: &url::Url) -> Option<&TextDocument> {
self.get_document(uri.as_str())
}
#[must_use]
pub fn get_document_text(&self, uri: &url::Url) -> Option<Arc<str>> {
let document = self.get_document_by_url(uri)?;
let file_id = document.file_id();
let snapshot = self.vfs.snapshot();
snapshot.get_text(file_id)
}
#[must_use]
pub fn get_line_text(&self, uri: &url::Url, line: u32) -> Option<String> {
let document = self.get_document_by_url(uri)?;
let snapshot = self.vfs.snapshot();
let content = snapshot.get_text(document.file_id())?;
document.get_line(content.as_ref(), line)
}
#[must_use]
pub fn get_word_at_position(&self, uri: &url::Url, position: Position) -> Option<String> {
// This is a simplified implementation - get the line and extract word at position
let line_text = self.get_line_text(uri, position.line)?;
let char_pos: usize = position.character.try_into().ok()?;
if char_pos >= line_text.len() {
return None;
}
// Find word boundaries (simplified - considers alphanumeric and underscore as word chars)
let line_bytes = line_text.as_bytes();
let mut start = char_pos;
let mut end = char_pos;
// Find start of word
while start > 0 && is_word_char(line_bytes[start - 1]) {
start -= 1;
}
// Find end of word
while end < line_text.len() && is_word_char(line_bytes[end]) {
end += 1;
}
if start < end {
Some(line_text[start..end].to_string())
} else {
None
}
}
// Position mapping methods
#[must_use]
pub fn offset_to_position(&self, uri: &url::Url, offset: usize) -> Option<Position> {
let document = self.get_document_by_url(uri)?;
Some(document.offset_to_position(offset as u32))
}
#[must_use]
pub fn position_to_offset(&self, uri: &url::Url, position: Position) -> Option<usize> {
let document = self.get_document_by_url(uri)?;
document
.position_to_offset(position)
.map(|offset| offset as usize)
}
// Template-specific methods
#[must_use]
pub fn get_template_ast(&self, uri: &url::Url) -> Option<Arc<TemplateAst>> {
let document = self.get_document_by_url(uri)?;
let file_id = document.file_id();
let file_store = self.file_store.lock().unwrap();
file_store.get_template_ast(file_id)
}
#[must_use]
pub fn get_template_errors(&self, uri: &url::Url) -> Vec<String> {
let Some(document) = self.get_document_by_url(uri) else {
return vec![];
};
let file_id = document.file_id();
let file_store = self.file_store.lock().unwrap();
let errors = file_store.get_template_errors(file_id);
errors.to_vec()
}
#[must_use]
pub fn get_template_context(
&self,
uri: &url::Url,
position: Position,
) -> Option<crate::TemplateTagContext> {
let document = self.get_document_by_url(uri)?;
let snapshot = self.vfs.snapshot();
let content = snapshot.get_text(document.file_id())?;
document.get_template_tag_context(content.as_ref(), position)
}
#[must_use]
pub fn get_completions(
&self,
uri: &str,
position: Position,
tags: &TemplateTags,
) -> Option<CompletionResponse> {
// Check if this is a Django template using TextDocument metadata
let document = self.get_document(uri)?;
if document.language_id() != LanguageId::HtmlDjango {
return None;
}
// Try to get cached AST from FileStore for better context analysis
// This demonstrates using the cached AST, though we still fall back to string parsing
let file_id = document.file_id();
let file_store = self.file_store.lock().unwrap();
if let Some(_ast) = file_store.get_template_ast(file_id) {
// TODO: In a future enhancement, we could use the AST to provide
// more intelligent completions based on the current node context
// For now, we continue with the existing string-based approach
}
// Get template tag context from document
let vfs_snapshot = self.vfs.snapshot();
let text_content = vfs_snapshot.get_text(file_id)?;
let context = document.get_template_tag_context(text_content.as_ref(), position)?;
let mut completions: Vec<CompletionItem> = tags
.iter()
.filter(|tag| {
context.partial_tag.is_empty() || tag.name().starts_with(&context.partial_tag)
})
.map(|tag| {
let leading_space = if context.needs_leading_space { " " } else { "" };
CompletionItem {
label: tag.name().to_string(),
kind: Some(CompletionItemKind::KEYWORD),
detail: Some(format!("Template tag from {}", tag.library())),
documentation: tag.doc().as_ref().map(|doc| {
Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: (*doc).to_string(),
})
}),
insert_text: Some(match context.closing_brace {
ClosingBrace::None => format!("{}{} %}}", leading_space, tag.name()),
ClosingBrace::PartialClose => format!("{}{} %", leading_space, tag.name()),
ClosingBrace::FullClose => format!("{}{} ", leading_space, tag.name()),
}),
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
..Default::default()
}
})
.collect();
if completions.is_empty() {
None
} else {
completions.sort_by(|a, b| a.label.cmp(&b.label));
Some(CompletionResponse::Array(completions))
}
}
/// Get template parsing diagnostics for a file.
///
/// This method uses the cached template errors from Salsa to generate LSP diagnostics.
/// The errors are only re-computed when the file content changes, providing efficient
/// incremental error reporting.
pub fn get_template_diagnostics(&self, uri: &str) -> Vec<Diagnostic> {
let Some(document) = self.get_document(uri) else {
return vec![];
};
// Only process template files
if document.language_id() != LanguageId::HtmlDjango {
return vec![];
}
let file_id = document.file_id();
let Some(_line_index) = self.get_line_index(uri) else {
return vec![];
};
// Get cached template errors from FileStore
let file_store = self.file_store.lock().unwrap();
let errors = file_store.get_template_errors(file_id);
// Convert template errors to LSP diagnostics
errors
.iter()
.map(|error| {
// For now, we'll place all errors at the start of the file
// In a future enhancement, we could use error spans for precise locations
let range = Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 0,
},
};
Diagnostic {
range,
severity: Some(DiagnosticSeverity::ERROR),
source: Some("djls-templates".to_string()),
message: error.clone(),
..Default::default()
}
})
.collect()
}
}
/// Check if a byte represents a word character (alphanumeric or underscore)
fn is_word_char(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
/// Apply text changes to content, handling multiple changes correctly
fn apply_text_changes(
content: &str,
changes: &[TextDocumentContentChangeEvent],
line_index: &LineIndex,
) -> Result<String> {
if changes.is_empty() {
return Ok(content.to_string());
}
// Check for full document replacement first
for change in changes {
if change.range.is_none() {
return Ok(change.text.clone());
}
}
// Sort changes by start position in reverse order (end to start)
let mut sorted_changes = changes.to_vec();
sorted_changes.sort_by(|a, b| {
match (a.range, b.range) {
(Some(range_a), Some(range_b)) => {
// Primary sort: by line (reverse)
let line_cmp = range_b.start.line.cmp(&range_a.start.line);
if line_cmp == std::cmp::Ordering::Equal {
// Secondary sort: by character (reverse)
range_b.start.character.cmp(&range_a.start.character)
} else {
line_cmp
}
}
_ => std::cmp::Ordering::Equal,
}
});
let mut result = content.to_string();
for change in &sorted_changes {
if let Some(range) = change.range {
// Convert UTF-16 positions to UTF-8 offsets
let start_offset = line_index
.offset_utf16(range.start, &result)
.ok_or_else(|| anyhow!("Invalid start position: {:?}", range.start))?;
let end_offset = line_index
.offset_utf16(range.end, &result)
.ok_or_else(|| anyhow!("Invalid end position: {:?}", range.end))?;
if start_offset as usize > result.len() || end_offset as usize > result.len() {
return Err(anyhow!(
"Offset out of bounds: start={}, end={}, len={}",
start_offset,
end_offset,
result.len()
));
}
// Apply the change
result.replace_range(start_offset as usize..end_offset as usize, &change.text);
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use tower_lsp_server::lsp_types::Range;
use super::*;
#[test]
fn test_apply_single_character_insertion() {
let content = "Hello world";
let line_index = LineIndex::new(content);
let changes = vec![TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(0, 6), Position::new(0, 6))),
range_length: None,
text: "beautiful ".to_string(),
}];
let result = apply_text_changes(content, &changes, &line_index).unwrap();
assert_eq!(result, "Hello beautiful world");
}
#[test]
fn test_apply_single_character_deletion() {
let content = "Hello world";
let line_index = LineIndex::new(content);
let changes = vec![TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(0, 5), Position::new(0, 6))),
range_length: None,
text: String::new(),
}];
let result = apply_text_changes(content, &changes, &line_index).unwrap();
assert_eq!(result, "Helloworld");
}
#[test]
fn test_apply_multiple_changes_in_reverse_order() {
let content = "line 1\nline 2\nline 3";
let line_index = LineIndex::new(content);
// Insert "new " at position (1, 0) and "another " at position (0, 0)
let changes = vec![
TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(0, 0), Position::new(0, 0))),
range_length: None,
text: "another ".to_string(),
},
TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(1, 0), Position::new(1, 0))),
range_length: None,
text: "new ".to_string(),
},
];
let result = apply_text_changes(content, &changes, &line_index).unwrap();
assert_eq!(result, "another line 1\nnew line 2\nline 3");
}
#[test]
fn test_apply_multiline_replacement() {
let content = "line 1\nline 2\nline 3";
let line_index = LineIndex::new(content);
let changes = vec![TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(0, 0), Position::new(2, 6))),
range_length: None,
text: "completely new content".to_string(),
}];
let result = apply_text_changes(content, &changes, &line_index).unwrap();
assert_eq!(result, "completely new content");
}
#[test]
fn test_apply_full_document_replacement() {
let content = "old content";
let line_index = LineIndex::new(content);
let changes = vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: "brand new content".to_string(),
}];
let result = apply_text_changes(content, &changes, &line_index).unwrap();
assert_eq!(result, "brand new content");
}
#[test]
fn test_utf16_line_index_basic() {
let content = "hello world";
let line_index = LineIndex::new(content);
// ASCII characters should have 1:1 UTF-8:UTF-16 mapping
let pos = Position::new(0, 6);
let offset = line_index.offset_utf16(pos, content).unwrap();
assert_eq!(offset, 6);
assert_eq!(&content[6..7], "w");
}
#[test]
fn test_utf16_line_index_with_emoji() {
let content = "hello 👋 world";
let line_index = LineIndex::new(content);
// 👋 is 2 UTF-16 code units but 4 UTF-8 bytes
let pos_after_emoji = Position::new(0, 8); // UTF-16 position after "hello 👋"
let offset = line_index.offset_utf16(pos_after_emoji, content).unwrap();
// Should point to the space before "world"
assert_eq!(offset, 10); // UTF-8 byte offset
assert_eq!(&content[10..11], " ");
}
#[test]
fn test_utf16_line_index_multiline() {
let content = "first line\nsecond line";
let line_index = LineIndex::new(content);
let pos = Position::new(1, 7); // Position at 'l' in "line" on second line
let offset = line_index.offset_utf16(pos, content).unwrap();
assert_eq!(offset, 18); // 11 (first line + \n) + 7
assert_eq!(&content[18..19], "l");
}
#[test]
fn test_apply_changes_with_emoji() {
let content = "hello 👋 world";
let line_index = LineIndex::new(content);
// Insert text after the space following the emoji (UTF-16 position 9)
let changes = vec![TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(0, 9), Position::new(0, 9))),
range_length: None,
text: "beautiful ".to_string(),
}];
let result = apply_text_changes(content, &changes, &line_index).unwrap();
assert_eq!(result, "hello 👋 beautiful world");
}
#[test]
fn test_line_index_utf16_tracking() {
let content = "a👋b";
let line_index = LineIndex::new(content);
// Check UTF-16 line starts are tracked correctly
assert_eq!(line_index.line_starts_utf16, vec![0]);
assert_eq!(line_index.length_utf16, 4); // 'a' (1) + 👋 (2) + 'b' (1) = 4 UTF-16 units
assert_eq!(line_index.length, 6); // 'a' (1) + 👋 (4) + 'b' (1) = 6 UTF-8 bytes
}
#[test]
fn test_edge_case_changes_at_boundaries() {
let content = "abc";
let line_index = LineIndex::new(content);
// Insert at beginning
let changes = vec![TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(0, 0), Position::new(0, 0))),
range_length: None,
text: "start".to_string(),
}];
let result = apply_text_changes(content, &changes, &line_index).unwrap();
assert_eq!(result, "startabc");
// Insert at end
let line_index = LineIndex::new(content);
let changes = vec![TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(0, 3), Position::new(0, 3))),
range_length: None,
text: "end".to_string(),
}];
let result = apply_text_changes(content, &changes, &line_index).unwrap();
assert_eq!(result, "abcend");
}
}

View file

@ -1,22 +1,33 @@
mod bridge;
mod db;
pub mod db;
mod document;
mod vfs;
mod lsp_system;
mod system;
pub use document::ClosingBrace;
pub use document::DocumentStore;
pub use document::LanguageId;
pub use document::LineIndex;
pub use document::TemplateTagContext;
pub use document::TextDocument;
pub use db::Database;
pub use document::{TextDocument, LanguageId};
pub use system::{FileSystem, StdFileSystem};
/// File classification for routing to analyzers.
///
/// [`FileKind`] determines how a file should be processed by downstream analyzers.
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub enum FileKind {
/// Python source file
Python,
/// Django template file
Template,
/// Other file type
Other,
}
/// 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.
/// lifetime of the system.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
pub(crate) struct FileId(u32);
pub struct FileId(u32);
impl FileId {
/// Create a [`FileId`] from a raw u32 value.
@ -27,6 +38,7 @@ impl FileId {
/// Get the underlying u32 index value.
#[must_use]
#[allow(dead_code)]
pub fn index(self) -> u32 {
self.0
}

View file

@ -0,0 +1,154 @@
//! LSP-aware file system wrapper that handles overlays
//!
//! This is the KEY pattern from Ruff - the LspSystem wraps a FileSystem
//! and intercepts reads to check for overlays first. This allows unsaved
//! changes to be used without going through Salsa.
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use url::Url;
use crate::system::FileSystem;
/// LSP-aware file system that checks overlays before disk
///
/// This is the critical piece that makes overlays work efficiently in Ruff's
/// architecture. Instead of updating Salsa for every keystroke, we intercept
/// file reads here and return overlay content when available.
pub struct LspSystem {
/// The underlying file system (usually StdFileSystem)
inner: Arc<dyn FileSystem>,
/// Map of open document URLs to their overlay content
overlays: HashMap<Url, String>,
}
impl LspSystem {
/// Create a new LspSystem wrapping the given file system
pub fn new(file_system: Arc<dyn FileSystem>) -> Self {
Self {
inner: file_system,
overlays: HashMap::new(),
}
}
/// Set overlay content for a document
pub fn set_overlay(&mut self, url: Url, content: String) {
self.overlays.insert(url, content);
}
/// Remove overlay content for a document
pub fn remove_overlay(&mut self, url: &Url) {
self.overlays.remove(url);
}
/// Check if a document has an overlay
pub fn has_overlay(&self, url: &Url) -> bool {
self.overlays.contains_key(url)
}
/// Get overlay content if it exists
pub fn get_overlay(&self, url: &Url) -> Option<&String> {
self.overlays.get(url)
}
/// Convert a URL to a file path
fn url_to_path(url: &Url) -> Option<PathBuf> {
if url.scheme() == "file" {
url.to_file_path().ok().or_else(|| {
// Fallback for simple conversion
Some(PathBuf::from(url.path()))
})
} else {
None
}
}
}
impl FileSystem for LspSystem {
fn read_to_string(&self, path: &Path) -> io::Result<String> {
// First check if we have an overlay for this path
// Convert path to URL for lookup
let url = Url::from_file_path(path)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Invalid path"))?;
if let Some(content) = self.overlays.get(&url) {
// Return overlay content instead of reading from disk
return Ok(content.clone());
}
// No overlay, read from underlying file system
self.inner.read_to_string(path)
}
fn exists(&self, path: &Path) -> bool {
// Check overlays first
if let Ok(url) = Url::from_file_path(path) {
if self.overlays.contains_key(&url) {
return true;
}
}
self.inner.exists(path)
}
fn is_file(&self, path: &Path) -> bool {
// Overlays are always files
if let Ok(url) = Url::from_file_path(path) {
if self.overlays.contains_key(&url) {
return true;
}
}
self.inner.is_file(path)
}
fn is_directory(&self, path: &Path) -> bool {
// Overlays are never directories
if let Ok(url) = Url::from_file_path(path) {
if self.overlays.contains_key(&url) {
return false;
}
}
self.inner.is_directory(path)
}
fn read_directory(&self, path: &Path) -> io::Result<Vec<std::path::PathBuf>> {
// Overlays don't affect directory listings
self.inner.read_directory(path)
}
fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata> {
// Can't provide metadata for overlays
self.inner.metadata(path)
}
}
/// Extension trait for working with URL-based overlays
pub trait LspSystemExt {
/// Read file content by URL, checking overlays first
fn read_url(&self, url: &Url) -> io::Result<String>;
}
impl LspSystemExt for LspSystem {
fn read_url(&self, url: &Url) -> io::Result<String> {
// Check overlays first
if let Some(content) = self.overlays.get(url) {
return Ok(content.clone());
}
// Convert URL to path and read from file system
if let Some(path_buf) = Self::url_to_path(url) {
self.inner.read_to_string(&path_buf)
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Cannot convert URL to path: {}", url),
))
}
}
}

View file

@ -0,0 +1,118 @@
//! File system abstraction following Ruff's pattern
//!
//! 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::path::Path;
/// Trait for file system operations
///
/// This follows Ruff's pattern of abstracting file system operations behind a trait,
/// allowing different implementations for testing, in-memory operation, and real file access.
pub trait FileSystem: Send + Sync {
/// Read the entire contents of a file
fn read_to_string(&self, path: &Path) -> io::Result<String>;
/// Check if a path exists
fn exists(&self, path: &Path) -> bool;
/// Check if a path is a file
fn is_file(&self, path: &Path) -> bool;
/// Check if a path is a directory
fn is_directory(&self, path: &Path) -> bool;
/// List directory contents
fn read_directory(&self, path: &Path) -> io::Result<Vec<std::path::PathBuf>>;
/// Get file metadata (size, modified time, etc.)
fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata>;
}
/// Standard file system implementation that uses std::fs
pub struct StdFileSystem;
impl FileSystem for StdFileSystem {
fn read_to_string(&self, path: &Path) -> io::Result<String> {
std::fs::read_to_string(path)
}
fn exists(&self, path: &Path) -> bool {
path.exists()
}
fn is_file(&self, path: &Path) -> bool {
path.is_file()
}
fn is_directory(&self, path: &Path) -> bool {
path.is_dir()
}
fn read_directory(&self, path: &Path) -> io::Result<Vec<std::path::PathBuf>> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(path)? {
entries.push(entry?.path());
}
Ok(entries)
}
fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata> {
std::fs::metadata(path)
}
}
/// In-memory file system for testing
#[cfg(test)]
pub struct MemoryFileSystem {
files: std::collections::HashMap<std::path::PathBuf, String>,
}
#[cfg(test)]
impl MemoryFileSystem {
pub fn new() -> Self {
Self {
files: std::collections::HashMap::new(),
}
}
pub fn add_file(&mut self, path: std::path::PathBuf, content: String) {
self.files.insert(path, content);
}
}
#[cfg(test)]
impl FileSystem for MemoryFileSystem {
fn read_to_string(&self, path: &Path) -> io::Result<String> {
self.files
.get(path)
.cloned()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "File not found"))
}
fn exists(&self, path: &Path) -> bool {
self.files.contains_key(path)
}
fn is_file(&self, path: &Path) -> bool {
self.files.contains_key(path)
}
fn is_directory(&self, _path: &Path) -> bool {
// Simplified for testing - no directories in memory filesystem
false
}
fn read_directory(&self, _path: &Path) -> io::Result<Vec<std::path::PathBuf>> {
// Simplified for testing
Ok(Vec::new())
}
fn metadata(&self, _path: &Path) -> io::Result<std::fs::Metadata> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Metadata not supported in memory filesystem",
))
}
}

View file

@ -0,0 +1,25 @@
//! Test module to explore Salsa thread safety
#[cfg(test)]
mod tests {
use crate::db::Database;
use std::thread;
#[test]
fn test_database_clone() {
let db = Database::new();
let _db2 = db.clone();
println!("✅ Database can be cloned");
}
#[test]
#[ignore] // This will fail
fn test_database_send() {
let db = Database::new();
let db2 = db.clone();
thread::spawn(move || {
let _ = db2;
}).join().unwrap();
}
}

View file

@ -1,367 +0,0 @@
//! 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.
mod watcher;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::fs;
use std::hash::Hash;
use std::hash::Hasher;
use std::sync::atomic::AtomicU32;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use anyhow::anyhow;
use anyhow::Result;
use camino::Utf8PathBuf;
use dashmap::DashMap;
use url::Url;
use watcher::VfsWatcher;
use watcher::WatchConfig;
use watcher::WatchEvent;
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(crate) struct Revision(u64);
impl Revision {
/// Create a [`Revision`] from a raw u64 value.
#[must_use]
fn from_raw(raw: u64) -> Self {
Revision(raw)
}
/// Get the underlying u64 value.
#[must_use]
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, and classification.
#[derive(Clone, Debug)]
pub(crate) struct FileMeta {
/// The file's URI (typically file:// scheme)
uri: Url,
/// The file's path in the filesystem
path: Utf8PathBuf,
/// Classification for routing to analyzers
pub kind: FileKind,
}
/// 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(crate) 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>),
}
/// Content hash for efficient change detection.
///
/// [`FileHash`] encapsulates the hashing logic used to detect when file content
/// has changed, avoiding unnecessary recomputation in downstream systems like Salsa.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
struct FileHash(u64);
impl FileHash {
/// Compute hash from text source content.
fn from_text_source(src: &TextSource) -> Self {
let s: &str = match src {
TextSource::Disk(s) | TextSource::Overlay(s) | TextSource::Generated(s) => s,
};
let mut h = DefaultHasher::new();
s.hash(&mut h);
Self(h.finish())
}
/// Check if this hash differs from another, indicating content changed.
fn differs_from(self, other: Self) -> bool {
self.0 != other.0
}
/// Get raw hash value (for debugging/logging).
fn raw(self) -> u64 {
self.0
}
}
/// 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(crate) struct FileRecord {
/// File metadata (URI, path, kind, version)
pub meta: FileMeta,
/// Current text content and its source
text: TextSource,
/// Hash of current content for change detection
hash: FileHash,
}
/// 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,
/// Optional file system watcher for external change detection
watcher: std::sync::Mutex<Option<VfsWatcher>>,
/// Map from filesystem path to [`FileId`] for watcher events
by_path: DashMap<Utf8PathBuf, FileId>,
}
impl Vfs {
/// 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(crate) 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: path.clone(),
kind,
};
let hash = FileHash::from_text_source(&text);
self.by_uri.insert(uri, id);
self.by_path.insert(path, 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(crate) fn set_overlay(&self, id: FileId, new_text: Arc<str>) -> Result<(Revision, bool)> {
let mut rec = self
.files
.get_mut(&id)
.ok_or_else(|| anyhow!("unknown file: {:?}", id))?;
let next = TextSource::Overlay(new_text);
let new_hash = FileHash::from_text_source(&next);
let changed = new_hash.differs_from(rec.hash);
if changed {
rec.text = next;
rec.hash = new_hash;
self.head.fetch_add(1, Ordering::SeqCst);
}
Ok((
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(crate) 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(),
}
}
/// Enable file system watching with the given configuration.
///
/// This starts monitoring the specified root directories for external changes.
/// Returns an error if file watching is disabled in the config or fails to start.
pub fn enable_file_watching(&self, config: WatchConfig) -> Result<()> {
let watcher = VfsWatcher::new(config)?;
*self
.watcher
.lock()
.map_err(|e| anyhow!("Failed to lock watcher mutex: {}", e))? = Some(watcher);
Ok(())
}
/// Process pending file system events from the watcher.
///
/// This should be called periodically to sync external file changes into the VFS.
/// Returns the number of files that were updated.
pub fn process_file_events(&self) -> usize {
// Get events from the watcher
let events = {
let Ok(guard) = self.watcher.lock() else {
return 0; // Return 0 if mutex is poisoned
};
if let Some(watcher) = guard.as_ref() {
watcher.try_recv_events()
} else {
return 0;
}
};
let mut updated_count = 0;
for event in events {
match event {
WatchEvent::Modified(path) | WatchEvent::Created(path) => {
if let Err(e) = self.load_from_disk(&path) {
eprintln!("Failed to load file from disk: {path}: {e}");
} else {
updated_count += 1;
}
}
WatchEvent::Deleted(path) => {
// For now, we don't remove deleted files from VFS
// This maintains stable `FileId`s for consumers
eprintln!("File deleted (keeping in VFS): {path}");
}
WatchEvent::Renamed { from, to } => {
// Handle rename by updating the path mapping
if let Some(file_id) = self.by_path.remove(&from).map(|(_, id)| id) {
self.by_path.insert(to.clone(), file_id);
if let Err(e) = self.load_from_disk(&to) {
eprintln!("Failed to load renamed file: {to}: {e}");
} else {
updated_count += 1;
}
}
}
}
}
updated_count
}
/// Load a file's content from disk and update the VFS.
///
/// This method reads the file from the filesystem and updates the VFS entry
/// if the content has changed. It's used by the file watcher to sync external changes.
fn load_from_disk(&self, path: &Utf8PathBuf) -> Result<()> {
// Check if we have this file tracked
if let Some(file_id) = self.by_path.get(path).map(|entry| *entry.value()) {
// Read content from disk
let content = fs::read_to_string(path.as_std_path())
.map_err(|e| anyhow!("Failed to read file {}: {}", path, e))?;
let new_text = TextSource::Disk(Arc::from(content.as_str()));
let new_hash = FileHash::from_text_source(&new_text);
// Update the file if content changed
if let Some(mut record) = self.files.get_mut(&file_id) {
if new_hash.differs_from(record.hash) {
record.text = new_text;
record.hash = new_hash;
self.head.fetch_add(1, Ordering::SeqCst);
}
}
}
Ok(())
}
/// Check if file watching is currently enabled.
pub fn is_file_watching_enabled(&self) -> bool {
self.watcher.lock().map(|g| g.is_some()).unwrap_or(false) // Return false if mutex is poisoned
}
}
impl Default for Vfs {
fn default() -> Self {
Self {
next_file_id: AtomicU32::new(0),
by_uri: DashMap::new(),
files: DashMap::new(),
head: AtomicU64::new(0),
watcher: std::sync::Mutex::new(None),
by_path: DashMap::new(),
}
}
}
/// 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(crate) struct VfsSnapshot {
/// The global revision at the time of snapshot
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.
#[must_use]
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.
#[must_use]
pub fn meta(&self, id: FileId) -> Option<&FileMeta> {
self.files.get(&id).map(|r| &r.meta)
}
}

View file

@ -1,325 +0,0 @@
//! File system watching for VFS synchronization.
//!
//! This module provides file system watching capabilities to detect external changes
//! and synchronize them with the VFS. It uses cross-platform file watching with
//! debouncing to handle rapid changes efficiently.
use std::collections::HashMap;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use anyhow::anyhow;
use anyhow::Result;
use camino::Utf8PathBuf;
use notify::Config;
use notify::Event;
use notify::EventKind;
use notify::RecommendedWatcher;
use notify::RecursiveMode;
use notify::Watcher;
/// Event types that can occur in the file system.
///
/// [`WatchEvent`] represents the different types of file system changes that
/// the watcher can detect and process.
#[derive(Clone, Debug, PartialEq)]
pub enum WatchEvent {
/// A file was modified (content changed)
Modified(Utf8PathBuf),
/// A new file was created
Created(Utf8PathBuf),
/// A file was deleted
Deleted(Utf8PathBuf),
/// A file was renamed from one path to another
Renamed { from: Utf8PathBuf, to: Utf8PathBuf },
}
/// Configuration for the file watcher.
///
/// [`WatchConfig`] controls how the file watcher operates, including what
/// directories to watch and how to filter events.
#[derive(Clone, Debug)]
pub struct WatchConfig {
/// Whether file watching is enabled
pub enabled: bool,
/// Root directories to watch recursively
pub roots: Vec<Utf8PathBuf>,
/// Debounce time in milliseconds (collect events for this duration before processing)
pub debounce_ms: u64,
/// File patterns to include (e.g., ["*.py", "*.html"])
pub include_patterns: Vec<String>,
/// File patterns to exclude (e.g., ["__pycache__", ".git", "*.pyc"])
pub exclude_patterns: Vec<String>,
}
// TODO: Allow for user config instead of hardcoding defaults
impl Default for WatchConfig {
fn default() -> Self {
Self {
enabled: true,
roots: Vec::new(),
debounce_ms: 250,
include_patterns: vec!["*.py".to_string(), "*.html".to_string()],
exclude_patterns: vec![
"__pycache__".to_string(),
".git".to_string(),
".pyc".to_string(),
"node_modules".to_string(),
".venv".to_string(),
"venv".to_string(),
],
}
}
}
/// File system watcher for VFS synchronization.
///
/// [`VfsWatcher`] monitors the file system for changes and provides a channel
/// for consuming batched events. It handles debouncing and filtering internally.
pub struct VfsWatcher {
/// The underlying file system watcher
_watcher: RecommendedWatcher,
/// Receiver for processed watch events
rx: mpsc::Receiver<Vec<WatchEvent>>,
/// Configuration for the watcher
config: WatchConfig,
/// Handle to the background processing thread
_handle: thread::JoinHandle<()>,
}
impl VfsWatcher {
/// Create a new file watcher with the given configuration.
///
/// This starts watching the specified root directories and begins processing
/// events in a background thread.
pub fn new(config: WatchConfig) -> Result<Self> {
if !config.enabled {
return Err(anyhow!("File watching is disabled"));
}
let (event_tx, event_rx) = mpsc::channel();
let (watch_tx, watch_rx) = mpsc::channel();
// Create the file system watcher
let mut watcher = RecommendedWatcher::new(
move |res: notify::Result<Event>| {
if let Ok(event) = res {
let _ = event_tx.send(event);
}
},
Config::default(),
)?;
// Watch all root directories
for root in &config.roots {
let std_path = root.as_std_path();
if std_path.exists() {
watcher.watch(std_path, RecursiveMode::Recursive)?;
}
}
// Spawn background thread for event processing
let config_clone = config.clone();
let handle = thread::spawn(move || {
Self::process_events(&event_rx, &watch_tx, &config_clone);
});
Ok(Self {
_watcher: watcher,
rx: watch_rx,
config,
_handle: handle,
})
}
/// Get the next batch of processed watch events.
///
/// This is a non-blocking operation that returns immediately. If no events
/// are available, it returns an empty vector.
#[must_use]
pub fn try_recv_events(&self) -> Vec<WatchEvent> {
self.rx.try_recv().unwrap_or_default()
}
/// Background thread function for processing raw file system events.
///
/// This function handles debouncing, filtering, and batching of events before
/// sending them to the main thread for VFS synchronization.
fn process_events(
event_rx: &mpsc::Receiver<Event>,
watch_tx: &mpsc::Sender<Vec<WatchEvent>>,
config: &WatchConfig,
) {
let mut pending_events: HashMap<Utf8PathBuf, WatchEvent> = HashMap::new();
let mut last_batch_time = Instant::now();
let debounce_duration = Duration::from_millis(config.debounce_ms);
loop {
// Try to receive events with a timeout for batching
match event_rx.recv_timeout(Duration::from_millis(50)) {
Ok(event) => {
// Process the raw notify event into our WatchEvent format
if let Some(watch_events) = Self::convert_notify_event(event, config) {
for watch_event in watch_events {
let path = Self::get_event_path(&watch_event);
// Only keep the latest event for each path
pending_events.insert(path.clone(), watch_event);
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
// Timeout - check if we should flush pending events
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
// Channel disconnected, exit the thread
break;
}
}
// Check if we should flush pending events
if !pending_events.is_empty() && last_batch_time.elapsed() >= debounce_duration {
let events: Vec<WatchEvent> = pending_events.values().cloned().collect();
if watch_tx.send(events).is_err() {
// Main thread disconnected, exit
break;
}
pending_events.clear();
last_batch_time = Instant::now();
}
}
}
/// Convert a [`notify::Event`] into our [`WatchEvent`] format.
fn convert_notify_event(event: Event, config: &WatchConfig) -> Option<Vec<WatchEvent>> {
let mut watch_events = Vec::new();
for path in event.paths {
if let Ok(utf8_path) = Utf8PathBuf::try_from(path) {
if Self::should_include_path_static(&utf8_path, config) {
match event.kind {
EventKind::Create(_) => watch_events.push(WatchEvent::Created(utf8_path)),
EventKind::Modify(_) => watch_events.push(WatchEvent::Modified(utf8_path)),
EventKind::Remove(_) => watch_events.push(WatchEvent::Deleted(utf8_path)),
_ => {} // Ignore other event types for now
}
}
}
}
if watch_events.is_empty() {
None
} else {
Some(watch_events)
}
}
/// Static version of should_include_path for use in convert_notify_event.
fn should_include_path_static(path: &Utf8PathBuf, config: &WatchConfig) -> bool {
let path_str = path.as_str();
// Check exclude patterns first
for pattern in &config.exclude_patterns {
if path_str.contains(pattern) {
return false;
}
}
// If no include patterns, include everything (that's not excluded)
if config.include_patterns.is_empty() {
return true;
}
// Check include patterns
for pattern in &config.include_patterns {
if let Some(extension) = pattern.strip_prefix("*.") {
if path_str.ends_with(extension) {
return true;
}
} else if path_str.contains(pattern) {
return true;
}
}
false
}
/// Extract the path from a [`WatchEvent`].
fn get_event_path(event: &WatchEvent) -> &Utf8PathBuf {
match event {
WatchEvent::Modified(path) | WatchEvent::Created(path) | WatchEvent::Deleted(path) => {
path
}
WatchEvent::Renamed { to, .. } => to,
}
}
}
impl Drop for VfsWatcher {
fn drop(&mut self) {
// The background thread will exit when the event channel is dropped
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_watch_config_default() {
let config = WatchConfig::default();
assert!(config.enabled);
assert_eq!(config.debounce_ms, 250);
assert!(config.include_patterns.contains(&"*.py".to_string()));
assert!(config.exclude_patterns.contains(&".git".to_string()));
}
#[test]
fn test_should_include_path() {
let config = WatchConfig::default();
// Should include Python files
assert!(VfsWatcher::should_include_path_static(
&Utf8PathBuf::from("test.py"),
&config
));
// Should include HTML files
assert!(VfsWatcher::should_include_path_static(
&Utf8PathBuf::from("template.html"),
&config
));
// Should exclude .git files
assert!(!VfsWatcher::should_include_path_static(
&Utf8PathBuf::from(".git/config"),
&config
));
// Should exclude __pycache__ files
assert!(!VfsWatcher::should_include_path_static(
&Utf8PathBuf::from("__pycache__/test.pyc"),
&config
));
}
#[test]
fn test_watch_event_types() {
let path1 = Utf8PathBuf::from("test.py");
let path2 = Utf8PathBuf::from("new.py");
let modified = WatchEvent::Modified(path1.clone());
let created = WatchEvent::Created(path1.clone());
let deleted = WatchEvent::Deleted(path1.clone());
let renamed = WatchEvent::Renamed {
from: path1,
to: path2,
};
// Test that events can be created and compared
assert_ne!(modified, created);
assert_ne!(created, deleted);
assert_ne!(deleted, renamed);
}
}