1use crate::build::GraphBuilder;
2use crate::detect::Detector;
3use crate::error::{CodeSynapseError, Result};
4use crate::extract::Extractor;
5use crate::graph::MemoryGraphStore;
6use crate::ts_extract::{
7 AstroExtractor, CsprojExtractor, FortranExtractor, JsonPackageExtractor, MarkdownExtractor,
8 McpConfigExtractor, ObjCExtractor, PowerShellExtractor, RazorExtractor, SlnExtractor,
9 TsBashExtractor, TsCExtractor, TsCSharpExtractor, TsCppExtractor, TsElixirExtractor,
10 TsGoExtractor, TsJavaExtractor, TsJavaScriptExtractor, TsJuliaExtractor, TsKotlinExtractor,
11 TsPascalExtractor, TsPhpExtractor, TsPythonExtractor, TsRubyExtractor, TsRustExtractor,
12 TsSqlExtractor, TsSvelteExtractor, TsSwiftExtractor, TsTypeScriptExtractor, TsVueExtractor,
13 VerilogExtractor,
14};
15use crate::types::{Edge, Node};
16use ignore::gitignore::{Gitignore, GitignoreBuilder};
17use notify::{Event, EventKind, RecursiveMode, Watcher as NotifyWatcher};
18use std::collections::HashSet;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, Instant};
22
23const CODESYNAPSE_OUT: &str = "codesynapse-out";
24const DEFAULT_DEBOUNCE_MS: u64 = 500;
25
26const VCS_MARKERS: &[&str] = &[".git", ".hg", ".svn", "_darcs", ".fossil"];
27
28pub struct WatchConfig {
29 pub root: PathBuf,
30 pub debounce_ms: u64,
31}
32
33impl Default for WatchConfig {
34 fn default() -> Self {
35 WatchConfig {
36 root: PathBuf::from("."),
37 debounce_ms: DEFAULT_DEBOUNCE_MS,
38 }
39 }
40}
41
42pub struct RebuildResult {
43 pub changed_files: Vec<PathBuf>,
44 pub nodes: Vec<Node>,
45 pub edges: Vec<Edge>,
46 pub elapsed_ms: u64,
47}
48
49pub struct Watcher {
50 config: WatchConfig,
51}
52
53impl Watcher {
54 pub fn new(config: WatchConfig) -> Self {
55 Watcher { config }
56 }
57
58 pub fn run<F>(&self, on_rebuild: F) -> Result<()>
59 where
60 F: Fn(RebuildResult) + Send + Sync + 'static,
61 {
62 let root = self
63 .config
64 .root
65 .canonicalize()
66 .unwrap_or_else(|_| self.config.root.clone());
67 let debounce = Duration::from_millis(self.config.debounce_ms);
68
69 let pending: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));
70 let last_event: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
71
72 let pending_clone = Arc::clone(&pending);
73 let last_event_clone = Arc::clone(&last_event);
74 let root_clone = root.clone();
75 let ignore = build_ignore(&root);
76
77 let (tx, rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
78
79 let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
80 let _ = tx.send(res);
81 })
82 .map_err(|e| CodeSynapseError::Io(std::io::Error::other(e.to_string())))?;
83
84 watcher
85 .watch(&root, RecursiveMode::Recursive)
86 .map_err(|e| CodeSynapseError::Io(std::io::Error::other(e.to_string())))?;
87
88 let on_rebuild = Arc::new(on_rebuild);
89
90 loop {
91 match rx.recv_timeout(Duration::from_millis(50)) {
93 Ok(Ok(event)) => {
94 if !is_modification(&event.kind) {
95 continue;
96 }
97 let mut any = false;
98 for path in event.paths {
99 if is_watched_path(&path, &root_clone, &ignore) {
100 pending_clone.lock().unwrap().insert(path);
101 any = true;
102 }
103 }
104 if any {
105 *last_event_clone.lock().unwrap() = Some(Instant::now());
106 }
107 }
108 Ok(Err(_)) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
109 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
110 }
111
112 let should_rebuild = {
114 let last = last_event.lock().unwrap();
115 last.map(|t| t.elapsed() >= debounce).unwrap_or(false)
116 };
117
118 if should_rebuild {
119 let changed: Vec<PathBuf> = {
120 let mut lock = pending.lock().unwrap();
121 let batch: Vec<_> = lock.drain().collect();
122 *last_event.lock().unwrap() = None;
123 batch
124 };
125
126 if !changed.is_empty() {
127 let result = rebuild(&root, changed);
128 on_rebuild(result);
129 }
130 }
131 }
132
133 Ok(())
134 }
135}
136
137fn register_all_extractors(extractor: &mut Extractor) {
138 extractor.register("py", Box::new(TsPythonExtractor));
139 extractor.register("js", Box::new(TsJavaScriptExtractor));
140 extractor.register("jsx", Box::new(TsJavaScriptExtractor));
141 extractor.register("mjs", Box::new(TsJavaScriptExtractor));
142 extractor.register("cjs", Box::new(TsJavaScriptExtractor));
143 extractor.register("ts", Box::new(TsTypeScriptExtractor));
144 extractor.register("tsx", Box::new(TsTypeScriptExtractor));
145 extractor.register("mts", Box::new(TsTypeScriptExtractor));
146 extractor.register("cts", Box::new(TsTypeScriptExtractor));
147 extractor.register("go", Box::new(TsGoExtractor));
148 extractor.register("rs", Box::new(TsRustExtractor));
149 extractor.register("java", Box::new(TsJavaExtractor));
150 extractor.register("c", Box::new(TsCExtractor));
151 extractor.register("h", Box::new(TsCExtractor));
152 extractor.register("cpp", Box::new(TsCppExtractor));
153 extractor.register("cxx", Box::new(TsCppExtractor));
154 extractor.register("hpp", Box::new(TsCppExtractor));
155 extractor.register("cs", Box::new(TsCSharpExtractor));
156 extractor.register("kt", Box::new(TsKotlinExtractor));
157 extractor.register("kts", Box::new(TsKotlinExtractor));
158 extractor.register("swift", Box::new(TsSwiftExtractor));
159 extractor.register("php", Box::new(TsPhpExtractor));
160 extractor.register("rb", Box::new(TsRubyExtractor));
161 extractor.register("sql", Box::new(TsSqlExtractor));
162 extractor.register("sh", Box::new(TsBashExtractor));
163 extractor.register("bash", Box::new(TsBashExtractor));
164 extractor.register("vue", Box::new(TsVueExtractor));
165 extractor.register("svelte", Box::new(TsSvelteExtractor));
166 extractor.register("json", Box::new(JsonPackageExtractor));
167 extractor.register("mcp.json", Box::new(McpConfigExtractor));
168 extractor.register("jl", Box::new(TsJuliaExtractor));
169 extractor.register("ex", Box::new(TsElixirExtractor));
170 extractor.register("exs", Box::new(TsElixirExtractor));
171 extractor.register("pas", Box::new(TsPascalExtractor));
172 extractor.register("pp", Box::new(TsPascalExtractor));
173 extractor.register("ps1", Box::new(PowerShellExtractor));
174 extractor.register("psm1", Box::new(PowerShellExtractor));
175 extractor.register("psd1", Box::new(PowerShellExtractor));
176 extractor.register("md", Box::new(MarkdownExtractor));
177 extractor.register("mdx", Box::new(MarkdownExtractor));
178 extractor.register("v", Box::new(VerilogExtractor));
179 extractor.register("sv", Box::new(VerilogExtractor));
180 extractor.register("svh", Box::new(VerilogExtractor));
181 extractor.register("f", Box::new(FortranExtractor));
182 extractor.register("f90", Box::new(FortranExtractor));
183 extractor.register("f95", Box::new(FortranExtractor));
184 extractor.register("f03", Box::new(FortranExtractor));
185 extractor.register("f08", Box::new(FortranExtractor));
186 extractor.register("m", Box::new(ObjCExtractor));
187 extractor.register("mm", Box::new(ObjCExtractor));
188 extractor.register("astro", Box::new(AstroExtractor));
189 extractor.register("sln", Box::new(SlnExtractor));
190 extractor.register("csproj", Box::new(CsprojExtractor));
191 extractor.register("razor", Box::new(RazorExtractor));
192 extractor.register("cshtml", Box::new(RazorExtractor));
193}
194
195fn rebuild(root: &Path, changed_files: Vec<PathBuf>) -> RebuildResult {
196 let start = Instant::now();
197
198 let detector = Detector::new(root);
199 let all_files = detector.discover(root).unwrap_or_default();
200
201 let mut extractor = Extractor::new();
202 register_all_extractors(&mut extractor);
203 let file_data: Vec<(PathBuf, Vec<u8>)> = all_files
204 .iter()
205 .filter_map(|df| std::fs::read(&df.path).ok().map(|b| (df.path.clone(), b)))
206 .collect();
207
208 let pairs: Vec<(PathBuf, &[u8])> = file_data
209 .iter()
210 .map(|(p, b)| (p.clone(), b.as_slice()))
211 .collect();
212
213 let results = extractor.extract_all(&pairs);
214
215 let store = Box::new(MemoryGraphStore::new());
216 let builder = GraphBuilder::new(store);
217
218 let fragments: Vec<(String, Vec<Node>, Vec<Edge>)> = results
219 .into_iter()
220 .filter_map(|(path, res)| {
221 res.ok()
222 .map(|frag| (path.to_string_lossy().to_string(), frag.nodes, frag.edges))
223 })
224 .collect();
225
226 let _ = builder.build_from_fragments(fragments);
227
228 let nodes = builder.store().get_all_nodes().unwrap_or_default();
229 let edges = builder.store().get_all_edges().unwrap_or_default();
230 let elapsed_ms = start.elapsed().as_millis() as u64;
231
232 RebuildResult {
233 changed_files,
234 nodes,
235 edges,
236 elapsed_ms,
237 }
238}
239
240fn is_modification(kind: &EventKind) -> bool {
241 matches!(
242 kind,
243 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
244 )
245}
246
247fn is_watched_path(path: &Path, _root: &Path, ignore: &Gitignore) -> bool {
248 if path.components().any(|c| c.as_os_str() == CODESYNAPSE_OUT) {
250 return false;
251 }
252
253 for component in path.components() {
255 let name = component.as_os_str().to_string_lossy();
256 if name.starts_with('.') {
257 return false;
258 }
259 }
260
261 if ignore.matched_path_or_any_parents(path, false).is_ignore() {
263 return false;
264 }
265
266 let ext = path
268 .extension()
269 .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
270 .unwrap_or_default();
271
272 is_tracked_extension(&ext)
273}
274
275fn is_tracked_extension(ext: &str) -> bool {
276 const CODE: &[&str] = &[
277 ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".ejs", ".ets", ".go", ".rs", ".java",
278 ".groovy", ".gradle", ".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".rb", ".swift", ".kt",
279 ".kts", ".cs", ".scala", ".php", ".lua", ".luau", ".zig", ".ps1", ".ex", ".exs", ".m",
280 ".mm", ".jl", ".vue", ".svelte", ".astro", ".dart", ".sql", ".r", ".sh", ".bash", ".json",
281 ".pas", ".pp",
282 ];
283 const DOC: &[&str] = &[".md", ".mdx", ".qmd", ".txt", ".rst"];
284 const IMAGE: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"];
285
286 CODE.contains(&ext) || DOC.contains(&ext) || IMAGE.contains(&ext)
287}
288
289fn find_vcs_root(start: &Path) -> Option<PathBuf> {
290 let start = start.canonicalize().ok()?;
291 let mut current: Option<&Path> = Some(&start);
292 while let Some(dir) = current {
293 for marker in VCS_MARKERS {
294 if dir.join(marker).exists() {
295 return Some(dir.to_path_buf());
296 }
297 }
298 current = dir.parent();
299 }
300 None
301}
302
303fn build_ignore(root: &Path) -> Gitignore {
304 let ceiling = find_vcs_root(root).unwrap_or_else(|| root.to_path_buf());
305 let mut builder = GitignoreBuilder::new(&ceiling);
306
307 let mut dirs: Vec<PathBuf> = Vec::new();
308 let mut cur = root.to_path_buf();
309 loop {
310 dirs.push(cur.clone());
311 if cur == ceiling {
312 break;
313 }
314 match cur.parent() {
315 Some(p) => cur = p.to_path_buf(),
316 None => break,
317 }
318 }
319 dirs.reverse(); for dir in dirs {
322 let codesynapseignore = dir.join(".codesynapseignore");
323 if codesynapseignore.exists() {
324 let _ = builder.add(codesynapseignore);
325 } else {
326 let gitignore = dir.join(".gitignore");
327 if gitignore.exists() {
328 let _ = builder.add(gitignore);
329 }
330 }
331 }
332
333 builder
334 .build()
335 .unwrap_or_else(|_| GitignoreBuilder::new(root).build().unwrap())
336}
337
338#[cfg(test)]
343mod tests {
344 use super::*;
345 use std::fs;
346 use std::sync::atomic::{AtomicUsize, Ordering};
347 use std::thread;
348
349 fn make_test_dir(suffix: &str) -> PathBuf {
350 let dir = std::env::temp_dir().join(format!("codesynapse_watch_test_{suffix}"));
351 fs::create_dir_all(&dir).unwrap();
352 dir
353 }
354
355 fn temp_py(dir: &Path, name: &str, content: &str) -> PathBuf {
356 let p = dir.join(name);
357 fs::write(&p, content).unwrap();
358 p
359 }
360
361 #[test]
366 fn test_watch_config_defaults() {
367 let cfg = WatchConfig::default();
368 assert_eq!(cfg.debounce_ms, DEFAULT_DEBOUNCE_MS);
369 assert_eq!(cfg.root, PathBuf::from("."));
370 }
371
372 #[test]
377 fn test_is_watched_path_code_file() {
378 let dir = make_test_dir("code_file");
379 let ignore = build_ignore(&dir);
380 let p = dir.join("main.py");
381 assert!(is_watched_path(&p, &dir, &ignore));
382 }
383
384 #[test]
385 fn test_is_watched_path_doc_file() {
386 let dir = make_test_dir("doc_file");
387 let ignore = build_ignore(&dir);
388 let p = dir.join("README.md");
389 assert!(is_watched_path(&p, &dir, &ignore));
390 }
391
392 #[test]
393 fn test_is_watched_path_dotfile_rejected() {
394 let dir = make_test_dir("dotfile");
395 let ignore = build_ignore(&dir);
396 let p = dir.join(".hidden.py");
397 assert!(!is_watched_path(&p, &dir, &ignore));
398 }
399
400 #[test]
401 fn test_is_watched_path_codesynapse_out_rejected() {
402 let dir = make_test_dir("gout");
403 let ignore = build_ignore(&dir);
404 let p = dir.join("codesynapse-out").join("graph.json");
405 assert!(!is_watched_path(&p, &dir, &ignore));
406 }
407
408 #[test]
409 fn test_is_watched_path_image_accepted() {
410 let dir = make_test_dir("image");
411 let ignore = build_ignore(&dir);
412 let p = dir.join("logo.png");
413 assert!(is_watched_path(&p, &dir, &ignore));
414 }
415
416 #[test]
417 fn test_is_watched_path_unknown_ext_rejected() {
418 let dir = make_test_dir("unknown_ext");
419 let ignore = build_ignore(&dir);
420 let p = dir.join("archive.zip");
421 assert!(!is_watched_path(&p, &dir, &ignore));
422 }
423
424 #[test]
429 fn test_codesynapseignore_excludes_path() {
430 let dir = make_test_dir("codesynapseignore_excludes");
431 fs::write(dir.join(".codesynapseignore"), "vendor/\n").unwrap();
432 let ignore = build_ignore(&dir);
433 let p = dir.join("vendor").join("lib.py");
434 assert!(ignore.matched_path_or_any_parents(&p, false).is_ignore());
436 }
437
438 #[test]
439 fn test_codesynapseignore_falls_back_to_gitignore() {
440 let dir = make_test_dir("codesynapseignore_gitignore_fallback");
441 fs::write(dir.join(".gitignore"), "dist/\n").unwrap();
442 let ignore = build_ignore(&dir);
443 let p = dir.join("dist").join("bundle.js");
444 assert!(ignore.matched_path_or_any_parents(&p, false).is_ignore());
445 }
446
447 #[test]
448 fn test_codesynapseignore_takes_priority_over_gitignore() {
449 let dir = make_test_dir("codesynapseignore_priority");
450 fs::write(dir.join(".gitignore"), "src/\n").unwrap();
451 fs::write(dir.join(".codesynapseignore"), "!src/\n").unwrap();
452 let ignore = build_ignore(&dir);
453 let p = dir.join("src").join("main.py");
454 assert!(!ignore.matched_path_or_any_parents(&p, false).is_ignore());
455 }
456
457 #[test]
462 fn test_is_modification_create() {
463 assert!(is_modification(&EventKind::Create(
464 notify::event::CreateKind::File
465 )));
466 }
467
468 #[test]
469 fn test_is_modification_modify() {
470 assert!(is_modification(&EventKind::Modify(
471 notify::event::ModifyKind::Data(notify::event::DataChange::Content)
472 )));
473 }
474
475 #[test]
476 fn test_is_modification_remove() {
477 assert!(is_modification(&EventKind::Remove(
478 notify::event::RemoveKind::File
479 )));
480 }
481
482 #[test]
483 fn test_is_modification_access_rejected() {
484 assert!(!is_modification(&EventKind::Access(
485 notify::event::AccessKind::Read
486 )));
487 }
488
489 #[test]
494 fn test_tracked_extensions() {
495 assert!(is_tracked_extension(".py"));
496 assert!(is_tracked_extension(".ts"));
497 assert!(is_tracked_extension(".rs"));
498 assert!(is_tracked_extension(".md"));
499 assert!(is_tracked_extension(".png"));
500 assert!(!is_tracked_extension(".zip"));
501 assert!(!is_tracked_extension(".exe"));
502 assert!(!is_tracked_extension(""));
503 }
504
505 #[test]
510 fn test_rebuild_produces_nodes_for_py_file() {
511 let dir = make_test_dir("rebuild_nodes");
512 temp_py(
513 &dir,
514 "hello.py",
515 "class Greeter:\n def greet(self): pass\n",
516 );
517
518 let result = rebuild(&dir, vec![dir.join("hello.py")]);
519
520 assert!(
521 !result.nodes.is_empty(),
522 "expected at least one node from hello.py"
523 );
524 assert_eq!(result.changed_files.len(), 1);
525 }
526
527 #[test]
528 fn test_rebuild_empty_dir_no_panic() {
529 let dir = make_test_dir("rebuild_empty");
530 let result = rebuild(&dir, vec![]);
531 assert!(result.elapsed_ms < 5000);
532 }
533
534 #[test]
535 fn test_rebuild_multi_file() {
536 let dir = make_test_dir("rebuild_multi");
537 temp_py(&dir, "a.py", "def foo(): pass\n");
538 temp_py(&dir, "b.py", "def bar(): pass\n");
539
540 let changed = vec![dir.join("a.py"), dir.join("b.py")];
541 let result = rebuild(&dir, changed.clone());
542
543 assert!(result.nodes.len() >= 2);
544 assert_eq!(result.changed_files.len(), 2);
545 }
546
547 #[test]
552 fn test_watcher_fires_on_file_write() {
553 let root = make_test_dir("watcher_fires");
554
555 let counter = Arc::new(AtomicUsize::new(0));
556 let counter_clone = Arc::clone(&counter);
557
558 let cfg = WatchConfig {
559 root: root.clone(),
560 debounce_ms: 100,
561 };
562 let watcher = Arc::new(Watcher::new(cfg));
563 let watcher_clone = Arc::clone(&watcher);
564
565 let handle = thread::spawn(move || {
566 let _ = watcher_clone.run(move |_result| {
567 counter_clone.fetch_add(1, Ordering::SeqCst);
568 });
569 });
570
571 thread::sleep(Duration::from_millis(200));
573
574 fs::write(root.join("test.py"), "x = 1\n").unwrap();
576
577 thread::sleep(Duration::from_millis(600));
579
580 assert!(
581 counter.load(Ordering::SeqCst) >= 1,
582 "callback should have fired at least once"
583 );
584
585 drop(handle);
586 }
587
588 #[test]
589 fn test_watcher_ignores_dotfiles() {
590 let root = make_test_dir("watcher_dotfiles");
591
592 let counter = Arc::new(AtomicUsize::new(0));
593 let counter_clone = Arc::clone(&counter);
594
595 let cfg = WatchConfig {
596 root: root.clone(),
597 debounce_ms: 100,
598 };
599 let watcher = Arc::new(Watcher::new(cfg));
600 let watcher_clone = Arc::clone(&watcher);
601
602 let _handle = thread::spawn(move || {
603 let _ = watcher_clone.run(move |_| {
604 counter_clone.fetch_add(1, Ordering::SeqCst);
605 });
606 });
607
608 thread::sleep(Duration::from_millis(200));
609
610 fs::write(root.join(".hidden.py"), "x = 1\n").unwrap();
611
612 thread::sleep(Duration::from_millis(600));
613
614 assert_eq!(
615 counter.load(Ordering::SeqCst),
616 0,
617 "dotfile should not trigger rebuild"
618 );
619 }
620
621 #[test]
622 fn test_watcher_ignores_codesynapse_out() {
623 let root = make_test_dir("watcher_gout");
624 let out = root.join("codesynapse-out");
625 fs::create_dir_all(&out).unwrap();
626
627 let counter = Arc::new(AtomicUsize::new(0));
628 let counter_clone = Arc::clone(&counter);
629
630 let cfg = WatchConfig {
631 root: root.clone(),
632 debounce_ms: 100,
633 };
634 let watcher = Arc::new(Watcher::new(cfg));
635 let watcher_clone = Arc::clone(&watcher);
636
637 let _handle = thread::spawn(move || {
638 let _ = watcher_clone.run(move |_| {
639 counter_clone.fetch_add(1, Ordering::SeqCst);
640 });
641 });
642
643 thread::sleep(Duration::from_millis(200));
644
645 fs::write(out.join("graph.json"), "{}").unwrap();
646
647 thread::sleep(Duration::from_millis(600));
648
649 assert_eq!(
650 counter.load(Ordering::SeqCst),
651 0,
652 "codesynapse-out writes should not trigger rebuild"
653 );
654 }
655}