use std::fs;
use std::path::Path;
use serde_json::json;
use crate::directory::is_code_graph_data_dir;
use crate::errors::{log_debug, log_warn};
use crate::extraction::grammars::is_source_file;
use super::backend::{WatchStartError, create_watch_handle, max_dir_watches};
use super::state::FileWatcher;
use super::types::{EXHAUSTION_REASON, PendingInfo};
use super::util::{context, now_ms, path_string, relative_posix};
impl FileWatcher {
pub(super) fn start_recursive(&mut self) -> Result<(), WatchStartError> {
let mut handle = create_watch_handle(&self.project_root, true)?;
let watcher_ptr = self as *mut Self as usize;
handle.set_error_handler(Box::new(move |err| {
let watcher = unsafe { (watcher_ptr as *mut FileWatcher).as_mut() };
if let Some(watcher) = watcher {
watcher.handle_runtime_watch_error(err, None);
}
}));
self.recursive_watcher = Some(handle);
Ok(())
}
pub(super) fn start_per_directory(&mut self) {
let root = self.project_root.clone();
self.watch_tree(&root, false);
}
pub(super) fn watch_tree(&mut self, dir: &Path, mark_existing: bool) {
if self.stopped || self.degraded_reason.is_some() || self.inotify_limit_warned {
return;
}
if self.dir_watchers.contains_key(dir) {
return;
}
if self.dir_watchers.len() >= max_dir_watches() {
if !self.dir_cap_warned {
self.dir_cap_warned = true;
let ctx = context([("cap", json!(max_dir_watches()))]);
log_warn(
"File watcher hit directory-watch cap; remaining subtrees rely on manual/periodic sync",
Some(&ctx),
);
}
return;
}
match create_watch_handle(dir, false) {
Ok(mut handle) => {
let watcher_ptr = self as *mut Self as usize;
let watched_dir = dir.to_path_buf();
handle.set_error_handler(Box::new(move |err| {
let watcher = unsafe { (watcher_ptr as *mut FileWatcher).as_mut() };
if let Some(watcher) = watcher {
watcher.handle_runtime_watch_error(err, Some(watched_dir.clone()));
}
}));
self.dir_watchers.insert(dir.to_path_buf(), handle);
}
Err(WatchStartError::ResourceExhaustion(message)) => {
self.degrade(
EXHAUSTION_REASON,
context([("error", json!(message)), ("dir", json!(path_string(dir)))]),
);
return;
}
Err(WatchStartError::InotifyExhaustion(message)) => {
self.warn_inotify_limit(context([
("error", json!(message)),
("dir", json!(path_string(dir))),
]));
return;
}
Err(WatchStartError::Other(_)) => return,
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
if !self.should_ignore_dir(&path) {
self.watch_tree(&path, mark_existing);
}
} else if mark_existing && file_type.is_file() {
let rel = relative_posix(&self.project_root, &path);
self.handle_change(&rel);
}
}
}
pub(super) fn drain_watch_events(&mut self) {
let recursive_events = self
.recursive_watcher
.as_mut()
.map(|watcher| watcher.take_events())
.unwrap_or_default();
for path in recursive_events {
self.handle_path_event(None, &path);
}
let dirs = self.dir_watchers.keys().cloned().collect::<Vec<_>>();
for dir in dirs {
let events = self
.dir_watchers
.get_mut(&dir)
.map(|watcher| watcher.take_events())
.unwrap_or_default();
for path in events {
self.handle_path_event(Some(&dir), &path);
}
}
}
#[allow(dead_code)]
fn handle_dir_event(&mut self, dir: &Path, filename: &str) {
if self.stopped || filename.is_empty() {
return;
}
let full = dir.join(filename);
if fs::metadata(&full)
.map(|metadata| metadata.is_dir())
.unwrap_or(false)
{
if !self.should_ignore_dir(&full) {
self.watch_tree(&full, true);
}
return;
}
let rel = relative_posix(&self.project_root, &full);
self.handle_change(&rel);
}
fn handle_path_event(&mut self, watched_dir: Option<&Path>, path: &Path) {
if self.stopped {
return;
}
let full = if path.is_absolute() {
path.to_path_buf()
} else if let Some(dir) = watched_dir {
dir.join(path)
} else {
self.project_root.join(path)
};
if fs::metadata(&full)
.map(|metadata| metadata.is_dir())
.unwrap_or(false)
{
if !self.should_ignore_dir(&full) {
self.watch_tree(&full, true);
}
return;
}
let rel = relative_posix(&self.project_root, &full);
self.handle_change(&rel);
}
pub(super) fn handle_change(&mut self, rel: &str) {
if rel.is_empty() || rel == "." || rel.starts_with("..") {
return;
}
if self.is_always_ignored(rel) {
return;
}
if self
.ignore_matcher
.as_ref()
.is_some_and(|matcher| matcher.ignores(rel))
{
return;
}
if !is_source_file(rel) {
return;
}
let ctx = context([("file", json!(rel))]);
log_debug("File change detected", Some(&ctx));
if self.ready {
let now = now_ms();
let first_seen_ms = self
.pending_files
.get(rel)
.map(|info| info.first_seen_ms)
.unwrap_or(now);
self.pending_files.insert(
rel.to_owned(),
PendingInfo {
first_seen_ms,
last_seen_ms: now,
},
);
}
self.schedule_sync();
}
pub(super) fn unwatch_dir(&mut self, dir: &Path) {
if let Some(mut watcher) = self.dir_watchers.remove(dir) {
watcher.close();
}
}
fn is_always_ignored(&self, rel: &str) -> bool {
let top = rel.split('/').next().unwrap_or(rel);
is_code_graph_data_dir(top) || rel == ".git" || rel.starts_with(".git/")
}
fn should_ignore_dir(&self, dir_path: &Path) -> bool {
let rel = relative_posix(&self.project_root, dir_path);
if rel.is_empty() || rel == "." || rel.starts_with("..") {
return false;
}
if self.is_always_ignored(&rel) {
return true;
}
self.ignore_matcher
.as_ref()
.map(|matcher| matcher.ignores(&(rel + "/")))
.unwrap_or(false)
}
}