This commit is contained in:
Josh Thomas 2025-08-25 10:34:57 -05:00
parent fb768a86d5
commit 20163b50f8
5 changed files with 94 additions and 94 deletions

View file

@ -9,7 +9,10 @@ use std::{collections::HashMap, sync::Arc};
use salsa::Setter;
use super::{
db::{parse_template, template_errors, Database, FileKindMini, SourceFile, TemplateAst, TemplateLoaderOrder},
db::{
parse_template, template_errors, Database, FileKindMini, SourceFile, TemplateAst,
TemplateLoaderOrder,
},
vfs::{FileKind, VfsSnapshot},
FileId,
};
@ -31,6 +34,7 @@ pub struct FileStore {
impl FileStore {
/// Construct an empty store and DB.
#[must_use]
pub fn new() -> Self {
Self {
db: Database::default(),
@ -118,8 +122,7 @@ impl FileStore {
pub fn get_template_errors(&self, id: FileId) -> Arc<[String]> {
self.files
.get(&id)
.map(|sf| template_errors(&self.db, *sf))
.unwrap_or_else(|| Arc::from(vec![]))
.map_or_else(|| Arc::from(vec![]), |sf| template_errors(&self.db, *sf))
}
}

View file

@ -120,12 +120,15 @@ pub fn parse_template(db: &dyn salsa::Database, file: SourceFile) -> Option<Arc<
let text = file.text(db);
// Call the pure parsing function from djls-templates
match djls_templates::parse_template(&text) {
match djls_templates::parse_template(text) {
Ok((ast, errors)) => {
// Convert errors to strings
let error_strings = errors.into_iter().map(|e| e.to_string()).collect();
Some(Arc::new(TemplateAst { ast, errors: error_strings }))
},
Some(Arc::new(TemplateAst {
ast,
errors: error_strings,
}))
}
Err(err) => {
// Even on fatal errors, return an empty AST with the error
Some(Arc::new(TemplateAst {
@ -144,9 +147,7 @@ pub fn parse_template(db: &dyn salsa::Database, file: SourceFile) -> Option<Arc<
/// Returns an empty vector for non-template files.
#[salsa::tracked]
pub fn template_errors(db: &dyn salsa::Database, file: SourceFile) -> Arc<[String]> {
parse_template(db, file)
.map(|ast| Arc::from(ast.errors.clone()))
.unwrap_or_else(|| Arc::from(vec![]))
parse_template(db, file).map_or_else(|| Arc::from(vec![]), |ast| Arc::from(ast.errors.clone()))
}
#[cfg(test)]
@ -187,7 +188,8 @@ mod tests {
assert!(ast1.is_some());
// Change the content
let template_content2: Arc<str> = Arc::from("{% for item in items %}{{ item }}{% endfor %}");
let template_content2: Arc<str> =
Arc::from("{% for item in items %}{{ item }}{% endfor %}");
file.set_text(&mut db).to(template_content2);
// Parse again - should re-execute due to changed content

View file

@ -3,9 +3,11 @@ mod db;
mod vfs;
mod watcher;
// Re-export public API
pub use bridge::FileStore;
pub use db::{parse_template, template_errors, Database, FileKindMini, SourceFile, TemplateAst, TemplateLoaderOrder};
pub use db::{
parse_template, template_errors, Database, FileKindMini, SourceFile, TemplateAst,
TemplateLoaderOrder,
};
pub use vfs::{FileKind, FileMeta, FileRecord, Revision, TextSource, Vfs, VfsSnapshot};
pub use watcher::{VfsWatcher, WatchConfig, WatchEvent};

View file

@ -8,6 +8,7 @@ use anyhow::{anyhow, Result};
use camino::Utf8PathBuf;
use dashmap::DashMap;
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::{
collections::HashMap,
@ -18,7 +19,10 @@ use std::{
};
use url::Url;
use super::{FileId, watcher::{VfsWatcher, WatchConfig, WatchEvent}};
use super::{
watcher::{VfsWatcher, WatchConfig, WatchEvent},
FileId,
};
/// Monotonic counter representing global VFS state.
///
@ -115,7 +119,7 @@ pub struct Vfs {
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
/// Map from filesystem path to [`FileId`] for watcher events
by_path: DashMap<Utf8PathBuf, FileId>,
}
@ -155,10 +159,6 @@ impl Vfs {
/// (detected via hash comparison).
///
/// Returns a tuple of (new global revision, whether content changed).
///
/// # Errors
///
/// Returns an error if the provided `FileId` does not exist in the VFS.
pub fn set_overlay(&self, id: FileId, new_text: Arc<str>) -> Result<(Revision, bool)> {
let mut rec = self
.files
@ -200,7 +200,10 @@ impl Vfs {
/// 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().unwrap() = Some(watcher);
*self
.watcher
.lock()
.map_err(|e| anyhow!("Failed to lock watcher mutex: {}", e))? = Some(watcher);
Ok(())
}
@ -211,7 +214,9 @@ impl Vfs {
pub fn process_file_events(&self) -> usize {
// Get events from the watcher
let events = {
let guard = self.watcher.lock().unwrap();
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 {
@ -225,22 +230,22 @@ impl Vfs {
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);
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 FileIds for consumers
eprintln!("File deleted (keeping in VFS): {}", path);
// 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);
eprintln!("Failed to load renamed file: {to}: {e}");
} else {
updated_count += 1;
}
@ -256,8 +261,6 @@ impl 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<()> {
use std::fs;
// 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
@ -281,7 +284,7 @@ impl Vfs {
/// Check if file watching is currently enabled.
pub fn is_file_watching_enabled(&self) -> bool {
self.watcher.lock().unwrap().is_some()
self.watcher.lock().map(|g| g.is_some()).unwrap_or(false) // Return false if mutex is poisoned
}
}

View file

@ -27,10 +27,7 @@ pub enum WatchEvent {
/// A file was deleted
Deleted(Utf8PathBuf),
/// A file was renamed from one path to another
Renamed {
from: Utf8PathBuf,
to: Utf8PathBuf,
},
Renamed { from: Utf8PathBuf, to: Utf8PathBuf },
}
/// Configuration for the file watcher.
@ -51,6 +48,7 @@ pub struct WatchConfig {
pub exclude_patterns: Vec<String>,
}
// TODO: Allow for user config instead of hardcoding defaults
impl Default for WatchConfig {
fn default() -> Self {
Self {
@ -119,7 +117,7 @@ impl VfsWatcher {
// 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);
Self::process_events(&event_rx, &watch_tx, &config_clone);
});
Ok(Self {
@ -134,23 +132,19 @@ impl VfsWatcher {
///
/// 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> {
match self.rx.try_recv() {
Ok(events) => events,
Err(_) => Vec::new(),
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,
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();
@ -161,15 +155,14 @@ impl VfsWatcher {
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) {
if let Some(watch_events) = Self::convert_notify_event(event, config) {
for watch_event in watch_events {
if let Some(path) = Self::get_event_path(&watch_event) {
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
}
@ -180,11 +173,9 @@ impl VfsWatcher {
}
// Check if we should flush pending events
if !pending_events.is_empty()
&& last_batch_time.elapsed() >= debounce_duration
{
if !pending_events.is_empty() && last_batch_time.elapsed() >= debounce_duration {
let events: Vec<WatchEvent> = pending_events.values().cloned().collect();
if let Err(_) = watch_tx.send(events) {
if watch_tx.send(events).is_err() {
// Main thread disconnected, exit
break;
}
@ -194,7 +185,7 @@ impl VfsWatcher {
}
}
/// Convert a notify Event into our WatchEvent format.
/// 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();
@ -236,8 +227,7 @@ impl VfsWatcher {
// Check include patterns
for pattern in &config.include_patterns {
if pattern.starts_with("*.") {
let extension = &pattern[2..];
if let Some(extension) = pattern.strip_prefix("*.") {
if path_str.ends_with(extension) {
return true;
}
@ -249,13 +239,13 @@ impl VfsWatcher {
false
}
/// Extract the path from a WatchEvent.
fn get_event_path(event: &WatchEvent) -> Option<&Utf8PathBuf> {
/// Extract the path from a [`WatchEvent`].
fn get_event_path(event: &WatchEvent) -> &Utf8PathBuf {
match event {
WatchEvent::Modified(path) => Some(path),
WatchEvent::Created(path) => Some(path),
WatchEvent::Deleted(path) => Some(path),
WatchEvent::Renamed { to, .. } => Some(to),
WatchEvent::Modified(path) | WatchEvent::Created(path) | WatchEvent::Deleted(path) => {
path
}
WatchEvent::Renamed { to, .. } => to,
}
}
}
@ -270,7 +260,6 @@ impl Drop for VfsWatcher {
mod tests {
use super::*;
#[test]
fn test_watch_config_default() {
let config = WatchConfig::default();
@ -328,3 +317,4 @@ mod tests {
assert_ne!(deleted, renamed);
}
}