Skip to main content

remembrall_server/
watcher.rs

1//! Live file watcher for auto-reindexing on source changes.
2//!
3//! Watches one or more project directories and incrementally updates the code
4//! graph when files are created, modified, or removed.
5//!
6//! Design decisions:
7//! - 500ms debounce: agents make bursty writes (save + format + lint). We wait
8//!   for the burst to settle before re-parsing.
9//! - Per-file incremental: only the changed file is re-parsed. The rest of the
10//!   graph is left intact.
11//! - Cross-file import resolution is skipped for watcher updates. Relationships
12//!   from the initial index remain correct; the watcher only refreshes symbols
13//!   and intra-file relationships for the touched file.
14//! - If a file fails to parse (e.g. syntax error mid-edit), we log and skip.
15//!   The stale symbols remain in the graph until the file is saved in a valid
16//!   state again.
17
18use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::Duration;
22
23use notify::RecursiveMode;
24use notify_debouncer_full::{new_debouncer, DebounceEventResult};
25use tokio::sync::mpsc;
26use tokio::sync::Mutex;
27
28use remembrall_core::graph::layers::detect_layer;
29use remembrall_core::graph::store::GraphStore;
30use remembrall_core::parser::{
31    parse_go_file, parse_java_file, parse_kotlin_file, parse_python_file, parse_ruby_file,
32    parse_rust_file, parse_ts_file, FileParseResult, TsLang,
33};
34
35/// Extensions we care about. All others are silently ignored.
36const WATCHED_EXTENSIONS: &[&str] = &[
37    "py", "ts", "tsx", "js", "jsx", "rs", "rb", "go", "java", "kt", "kts",
38];
39
40/// Directories that are never meaningful source roots - changes inside them
41/// are discarded without touching the graph.
42const IGNORE_DIRS: &[&str] = &[
43    "node_modules",
44    ".git",
45    "target",
46    "__pycache__",
47    "vendor",
48    ".venv",
49    "venv",
50    "dist",
51    "build",
52    ".cache",
53    ".next",
54    ".nuxt",
55];
56
57// ---------------------------------------------------------------------------
58// Public API
59// ---------------------------------------------------------------------------
60
61/// Watches one or more project directories and keeps the graph up to date.
62///
63/// Constructed with [`FileWatcher::new`] then configured via [`FileWatcher::watch`].
64/// Call [`FileWatcher::run`] to start the event loop (runs until the future is
65/// dropped or the process exits).
66pub struct FileWatcher {
67    graph: Arc<GraphStore>,
68    /// Map of watched root path -> project name.
69    projects: Arc<Mutex<HashMap<PathBuf, String>>>,
70}
71
72impl FileWatcher {
73    pub fn new(graph: Arc<GraphStore>) -> Self {
74        Self {
75            graph,
76            projects: Arc::new(Mutex::new(HashMap::new())),
77        }
78    }
79
80    /// Register a project directory for watching.
81    ///
82    /// This is additive - multiple directories can be registered. The actual
83    /// OS-level watch is set up when [`run`] is called.
84    pub async fn add_project(&self, root: PathBuf, project: String) {
85        self.projects.lock().await.insert(root, project);
86    }
87
88    /// Start the event loop. Runs until the process exits or the future is
89    /// cancelled. Should be spawned as a background task.
90    ///
91    /// ```ignore
92    /// tokio::spawn(watcher.run());
93    /// ```
94    pub async fn run(self) {
95        let projects_snapshot: HashMap<PathBuf, String> = {
96            let guard = self.projects.lock().await;
97            guard.clone()
98        };
99
100        if projects_snapshot.is_empty() {
101            tracing::warn!("FileWatcher started with no projects - nothing to watch");
102            return;
103        }
104
105        // Channel between the notify callback thread and the tokio runtime.
106        // Buffer is generous: a large save burst can produce many events quickly.
107        let (tx, mut rx) = mpsc::channel::<Vec<PathBuf>>(256);
108
109        // Build the debounced watcher on a dedicated thread (notify requires
110        // the watcher to stay alive on the thread that created it, or at least
111        // be kept alive by ownership - we move it into a blocking task).
112        let roots: Vec<PathBuf> = projects_snapshot.keys().cloned().collect();
113        let tx_clone = tx.clone();
114
115        // spawn_blocking keeps the watcher alive and blocks the thread for the
116        // duration of the process. The watcher itself uses OS-level APIs and
117        // does not busy-loop.
118        tokio::task::spawn_blocking(move || {
119            let rt = tokio::runtime::Handle::current();
120
121            let result = new_debouncer(
122                Duration::from_millis(500),
123                None,
124                move |res: DebounceEventResult| {
125                    match res {
126                        Ok(events) => {
127                            let paths: Vec<PathBuf> = events
128                                .into_iter()
129                                .flat_map(|e| e.event.paths)
130                                .collect();
131                            if !paths.is_empty() {
132                                let tx = tx_clone.clone();
133                                rt.spawn(async move {
134                                    let _ = tx.send(paths).await;
135                                });
136                            }
137                        }
138                        Err(errs) => {
139                            for e in errs {
140                                tracing::warn!("watcher error: {e:?}");
141                            }
142                        }
143                    }
144                },
145            );
146
147            let mut debouncer = match result {
148                Ok(d) => d,
149                Err(e) => {
150                    tracing::error!("failed to create file watcher: {e}");
151                    return;
152                }
153            };
154
155            for root in &roots {
156                if let Err(e) = debouncer.watch(root, RecursiveMode::Recursive) {
157                    tracing::error!("failed to watch {}: {e}", root.display());
158                } else {
159                    tracing::info!("watching {} for changes", root.display());
160                }
161            }
162
163            // Block the thread forever so the debouncer stays alive.
164            // The channel sender keeps the other side notified when events arrive.
165            std::thread::park();
166        });
167
168        // Drop the original sender so the channel closes when the blocking
169        // task exits (which only happens if the process exits).
170        drop(tx);
171
172        // Process incoming change batches on the tokio side.
173        while let Some(paths) = rx.recv().await {
174            // Deduplicate - a single burst can contain the same path many times.
175            let unique: HashSet<PathBuf> = paths.into_iter().collect();
176
177            for path in unique {
178                // Only process files with watched extensions that are not in
179                // ignored directories.
180                if !is_watched_file(&path) {
181                    continue;
182                }
183
184                // Find which project this file belongs to.
185                let project = match find_project(&path, &projects_snapshot) {
186                    Some(p) => p,
187                    None => {
188                        tracing::debug!("change in unregistered path, skipping: {}", path.display());
189                        continue;
190                    }
191                };
192
193                reindex_file(&self.graph, &path, &project).await;
194            }
195        }
196
197        tracing::info!("FileWatcher event loop exited");
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Internal helpers
203// ---------------------------------------------------------------------------
204
205/// Re-index a single changed file.
206///
207/// Removes the file's old symbols from the graph, re-parses the file, and
208/// inserts the new symbols. Errors are logged but do not propagate - a syntax
209/// error mid-edit should never crash the watcher.
210async fn reindex_file(graph: &Arc<GraphStore>, path: &Path, project: &str) {
211    let file_path = path.to_string_lossy().to_string();
212
213    // File was deleted.
214    if !path.exists() {
215        match graph.remove_file(&file_path, project).await {
216            Ok(removed) if removed > 0 => {
217                tracing::info!("removed {} symbols for deleted file: {}", removed, file_path);
218            }
219            Ok(_) => {}
220            Err(e) => {
221                tracing::warn!("remove_file failed for {}: {e}", file_path);
222            }
223        }
224        return;
225    }
226
227    // Read source. If it fails (permissions, binary, etc.) log and skip.
228    let source = match std::fs::read_to_string(path) {
229        Ok(s) => s,
230        Err(e) => {
231            tracing::debug!("skipping {} - could not read: {e}", file_path);
232            return;
233        }
234    };
235
236    let ext = path
237        .extension()
238        .and_then(|e| e.to_str())
239        .unwrap_or("")
240        .to_lowercase();
241
242    let mtime = chrono::Utc::now();
243
244    // Parsers are synchronous/CPU-bound (tree-sitter). Run on a blocking thread
245    // to avoid stalling the tokio reactor.
246    let file_path_clone = file_path.clone();
247    let project_owned = project.to_string();
248    let parse_result: FileParseResult = match tokio::task::spawn_blocking(move || {
249        if ext == "py" {
250            parse_python_file(&file_path_clone, &source, &project_owned, mtime)
251        } else if ext == "rs" {
252            parse_rust_file(&file_path_clone, &source, &project_owned, mtime)
253        } else if ext == "rb" {
254            parse_ruby_file(&file_path_clone, &source, &project_owned, mtime)
255        } else if ext == "go" {
256            parse_go_file(&file_path_clone, &source, &project_owned, mtime)
257        } else if ext == "java" {
258            parse_java_file(&file_path_clone, &source, &project_owned, mtime)
259        } else if ext == "kt" || ext == "kts" {
260            parse_kotlin_file(&file_path_clone, &source, &project_owned, mtime)
261        } else if let Some(lang) = TsLang::from_extension(&ext) {
262            parse_ts_file(&file_path_clone, &source, &project_owned, mtime, lang)
263        } else {
264            FileParseResult::default()
265        }
266    })
267    .await
268    {
269        Ok(r) => r,
270        Err(e) => {
271            tracing::warn!("parse panicked for {}: {e}", file_path);
272            return;
273        }
274    };
275
276    // If the extension was not matched, the result will be empty - skip.
277    if parse_result.symbols.is_empty() && parse_result.relationships.is_empty() {
278        return;
279    }
280
281    // Apply architectural layer detection to all symbols in this file.
282    // The walker does this in its phase-2b loop; we must do it here too so
283    // watcher-reindexed symbols get the same layer metadata as initial index.
284    let layer = detect_layer(&file_path);
285    let mut parse_result = parse_result;
286    for sym in &mut parse_result.symbols {
287        sym.layer = layer.clone();
288    }
289
290    // Remove stale symbols for this file before upserting fresh ones.
291    if let Err(e) = graph.remove_file(&file_path, project).await {
292        tracing::warn!("remove_file failed for {}: {e}", file_path);
293        return;
294    }
295
296    // Upsert new symbols.
297    let mut symbols_stored = 0u64;
298    for symbol in &parse_result.symbols {
299        match graph.upsert_symbol(symbol).await {
300            Ok(_) => symbols_stored += 1,
301            Err(e) => {
302                tracing::warn!("upsert_symbol failed for {} in {}: {e}", symbol.name, file_path);
303            }
304        }
305    }
306
307    // Store intra-file relationships. Cross-file relationships (to symbols in
308    // other files) are skipped here - they were resolved during the initial
309    // full index and remain valid unless those other files also changed.
310    let mut rels_stored = 0u64;
311    for rel in &parse_result.relationships {
312        match graph.add_relationship(rel).await {
313            Ok(_) => rels_stored += 1,
314            Err(e) => {
315                tracing::debug!("skipping relationship in {}: {e}", file_path);
316            }
317        }
318    }
319
320    tracing::info!(
321        "reindexed {} - {} symbols, {} relationships",
322        file_path,
323        symbols_stored,
324        rels_stored,
325    );
326}
327
328/// Return true if this file should be watched (has a supported extension and
329/// is not inside an ignored directory).
330fn is_watched_file(path: &Path) -> bool {
331    // Must be a file path, not a directory.
332    let ext = match path.extension().and_then(|e| e.to_str()) {
333        Some(e) => e.to_lowercase(),
334        None => return false,
335    };
336
337    if !WATCHED_EXTENSIONS.contains(&ext.as_str()) {
338        return false;
339    }
340
341    // Check every component of the path against the ignore list.
342    for component in path.components() {
343        if let std::path::Component::Normal(name) = component {
344            let name = name.to_string_lossy();
345            if name.starts_with('.') && name != "." {
346                return false;
347            }
348            if IGNORE_DIRS.contains(&name.as_ref()) {
349                return false;
350            }
351        }
352    }
353
354    true
355}
356
357/// Find the registered project for a given file path by longest-prefix match.
358fn find_project<'a>(
359    path: &Path,
360    projects: &'a HashMap<PathBuf, String>,
361) -> Option<String> {
362    projects
363        .iter()
364        .filter(|(root, _)| path.starts_with(root))
365        .max_by_key(|(root, _)| root.as_os_str().len())
366        .map(|(_, name)| name.clone())
367}