use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use notify::RecursiveMode;
use notify_debouncer_full::{new_debouncer, DebounceEventResult};
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use remembrall_core::graph::layers::detect_layer;
use remembrall_core::graph::store::GraphStore;
use remembrall_core::parser::{
parse_go_file, parse_java_file, parse_kotlin_file, parse_python_file, parse_ruby_file,
parse_rust_file, parse_ts_file, FileParseResult, TsLang,
};
const WATCHED_EXTENSIONS: &[&str] = &[
"py", "ts", "tsx", "js", "jsx", "rs", "rb", "go", "java", "kt", "kts",
];
const IGNORE_DIRS: &[&str] = &[
"node_modules",
".git",
"target",
"__pycache__",
"vendor",
".venv",
"venv",
"dist",
"build",
".cache",
".next",
".nuxt",
];
pub struct FileWatcher {
graph: Arc<GraphStore>,
projects: Arc<Mutex<HashMap<PathBuf, String>>>,
}
impl FileWatcher {
pub fn new(graph: Arc<GraphStore>) -> Self {
Self {
graph,
projects: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn add_project(&self, root: PathBuf, project: String) {
self.projects.lock().await.insert(root, project);
}
pub async fn run(self) {
let projects_snapshot: HashMap<PathBuf, String> = {
let guard = self.projects.lock().await;
guard.clone()
};
if projects_snapshot.is_empty() {
tracing::warn!("FileWatcher started with no projects - nothing to watch");
return;
}
let (tx, mut rx) = mpsc::channel::<Vec<PathBuf>>(256);
let roots: Vec<PathBuf> = projects_snapshot.keys().cloned().collect();
let tx_clone = tx.clone();
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
let result = new_debouncer(
Duration::from_millis(500),
None,
move |res: DebounceEventResult| {
match res {
Ok(events) => {
let paths: Vec<PathBuf> = events
.into_iter()
.flat_map(|e| e.event.paths)
.collect();
if !paths.is_empty() {
let tx = tx_clone.clone();
rt.spawn(async move {
let _ = tx.send(paths).await;
});
}
}
Err(errs) => {
for e in errs {
tracing::warn!("watcher error: {e:?}");
}
}
}
},
);
let mut debouncer = match result {
Ok(d) => d,
Err(e) => {
tracing::error!("failed to create file watcher: {e}");
return;
}
};
for root in &roots {
if let Err(e) = debouncer.watch(root, RecursiveMode::Recursive) {
tracing::error!("failed to watch {}: {e}", root.display());
} else {
tracing::info!("watching {} for changes", root.display());
}
}
std::thread::park();
});
drop(tx);
while let Some(paths) = rx.recv().await {
let unique: HashSet<PathBuf> = paths.into_iter().collect();
for path in unique {
if !is_watched_file(&path) {
continue;
}
let project = match find_project(&path, &projects_snapshot) {
Some(p) => p,
None => {
tracing::debug!("change in unregistered path, skipping: {}", path.display());
continue;
}
};
reindex_file(&self.graph, &path, &project).await;
}
}
tracing::info!("FileWatcher event loop exited");
}
}
async fn reindex_file(graph: &Arc<GraphStore>, path: &Path, project: &str) {
let file_path = path.to_string_lossy().to_string();
if !path.exists() {
match graph.remove_file(&file_path, project).await {
Ok(removed) if removed > 0 => {
tracing::info!("removed {} symbols for deleted file: {}", removed, file_path);
}
Ok(_) => {}
Err(e) => {
tracing::warn!("remove_file failed for {}: {e}", file_path);
}
}
return;
}
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
tracing::debug!("skipping {} - could not read: {e}", file_path);
return;
}
};
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let mtime = chrono::Utc::now();
let file_path_clone = file_path.clone();
let project_owned = project.to_string();
let parse_result: FileParseResult = match tokio::task::spawn_blocking(move || {
if ext == "py" {
parse_python_file(&file_path_clone, &source, &project_owned, mtime)
} else if ext == "rs" {
parse_rust_file(&file_path_clone, &source, &project_owned, mtime)
} else if ext == "rb" {
parse_ruby_file(&file_path_clone, &source, &project_owned, mtime)
} else if ext == "go" {
parse_go_file(&file_path_clone, &source, &project_owned, mtime)
} else if ext == "java" {
parse_java_file(&file_path_clone, &source, &project_owned, mtime)
} else if ext == "kt" || ext == "kts" {
parse_kotlin_file(&file_path_clone, &source, &project_owned, mtime)
} else if let Some(lang) = TsLang::from_extension(&ext) {
parse_ts_file(&file_path_clone, &source, &project_owned, mtime, lang)
} else {
FileParseResult::default()
}
})
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("parse panicked for {}: {e}", file_path);
return;
}
};
if parse_result.symbols.is_empty() && parse_result.relationships.is_empty() {
return;
}
let layer = detect_layer(&file_path);
let mut parse_result = parse_result;
for sym in &mut parse_result.symbols {
sym.layer = layer.clone();
}
if let Err(e) = graph.remove_file(&file_path, project).await {
tracing::warn!("remove_file failed for {}: {e}", file_path);
return;
}
let mut symbols_stored = 0u64;
for symbol in &parse_result.symbols {
match graph.upsert_symbol(symbol).await {
Ok(_) => symbols_stored += 1,
Err(e) => {
tracing::warn!("upsert_symbol failed for {} in {}: {e}", symbol.name, file_path);
}
}
}
let mut rels_stored = 0u64;
for rel in &parse_result.relationships {
match graph.add_relationship(rel).await {
Ok(_) => rels_stored += 1,
Err(e) => {
tracing::debug!("skipping relationship in {}: {e}", file_path);
}
}
}
tracing::info!(
"reindexed {} - {} symbols, {} relationships",
file_path,
symbols_stored,
rels_stored,
);
}
fn is_watched_file(path: &Path) -> bool {
let ext = match path.extension().and_then(|e| e.to_str()) {
Some(e) => e.to_lowercase(),
None => return false,
};
if !WATCHED_EXTENSIONS.contains(&ext.as_str()) {
return false;
}
for component in path.components() {
if let std::path::Component::Normal(name) = component {
let name = name.to_string_lossy();
if name.starts_with('.') && name != "." {
return false;
}
if IGNORE_DIRS.contains(&name.as_ref()) {
return false;
}
}
}
true
}
fn find_project<'a>(
path: &Path,
projects: &'a HashMap<PathBuf, String>,
) -> Option<String> {
projects
.iter()
.filter(|(root, _)| path.starts_with(root))
.max_by_key(|(root, _)| root.as_os_str().len())
.map(|(_, name)| name.clone())
}