1use std::collections::{HashMap, HashSet};
13use std::path::Path;
14
15use super::context::ContextKey;
16use super::graph::DepGraph;
17use crate::core::NormalizedPath;
18
19#[derive(Debug, Clone, Default)]
24pub struct WatchSet {
25 dirs: HashMap<NormalizedPath, HashSet<String>>,
27}
28
29fn normalize_watch_filename(name: &std::ffi::OsStr) -> String {
30 #[cfg(windows)]
31 {
32 name.to_string_lossy().to_ascii_lowercase()
33 }
34
35 #[cfg(not(windows))]
36 {
37 name.to_string_lossy().into_owned()
38 }
39}
40
41impl WatchSet {
42 #[must_use]
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 #[must_use]
51 pub fn from_paths(paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
52 let mut dirs: HashMap<NormalizedPath, HashSet<String>> = HashMap::new();
53 for path in paths {
54 let path = path.as_ref();
55 if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
56 dirs.entry(parent.to_path_buf().into())
57 .or_default()
58 .insert(normalize_watch_filename(name));
59 }
60 }
61 Self { dirs }
62 }
63
64 pub fn add_dir(&mut self, dir: NormalizedPath) {
67 self.dirs.entry(dir).or_default();
68 }
69
70 pub fn add_path(&mut self, path: &Path) {
72 if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
73 self.dirs
74 .entry(parent.to_path_buf().into())
75 .or_default()
76 .insert(normalize_watch_filename(name));
77 }
78 }
79
80 pub fn dirs(&self) -> impl Iterator<Item = &NormalizedPath> {
82 self.dirs.keys()
83 }
84
85 #[must_use]
87 pub fn is_tracked(&self, path: &Path) -> bool {
88 if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
89 let parent = NormalizedPath::new(parent);
90 self.dirs
91 .get(&parent)
92 .is_some_and(|names| names.contains(&normalize_watch_filename(name)))
93 } else {
94 false
95 }
96 }
97
98 #[must_use]
100 pub fn is_watched(&self, dir: &Path) -> bool {
101 self.dirs.contains_key(&NormalizedPath::new(dir))
102 }
103
104 #[must_use]
106 pub fn dir_count(&self) -> usize {
107 self.dirs.len()
108 }
109
110 #[must_use]
112 pub fn file_count(&self) -> usize {
113 self.dirs.values().map(HashSet::len).sum()
114 }
115
116 #[must_use]
119 pub fn new_dirs_vs(&self, previous: &WatchSet) -> Vec<NormalizedPath> {
120 self.dirs
121 .keys()
122 .filter(|d| !previous.dirs.contains_key(*d))
123 .cloned()
124 .collect()
125 }
126
127 #[must_use]
130 pub fn removed_dirs_vs(&self, previous: &WatchSet) -> Vec<NormalizedPath> {
131 previous
132 .dirs
133 .keys()
134 .filter(|d| !self.dirs.contains_key(*d))
135 .cloned()
136 .collect()
137 }
138}
139
140fn is_higher_priority(
145 dir_a: &Path,
146 dir_b: &Path,
147 search: &super::search_paths::IncludeSearchPaths,
148) -> bool {
149 let all_dirs: Vec<&Path> = search.all_search_dirs().collect();
150
151 let pos_a = all_dirs.iter().position(|d| *d == dir_a);
152 let pos_b = all_dirs.iter().position(|d| *d == dir_b);
153
154 match (pos_a, pos_b) {
155 (Some(a), Some(b)) => a < b,
156 _ => false,
157 }
158}
159
160impl DepGraph {
161 #[must_use]
168 pub fn watch_set(&self) -> WatchSet {
169 let mut ws = WatchSet::new();
170
171 for entry in self.contexts_iter() {
172 let ctx_entry = entry.value();
173
174 ws.add_path(&ctx_entry.context.source_file);
176
177 for inc in &ctx_entry.resolved_includes {
179 ws.add_path(inc);
180 }
181
182 for dir in ctx_entry.context.include_search.all_search_dirs() {
184 ws.add_dir(dir.into());
185 }
186 }
187
188 ws
189 }
190
191 #[must_use]
198 pub fn check_shadow(&self, new_file: &Path) -> Vec<ContextKey> {
199 let new_name = match new_file.file_name() {
200 Some(n) => n.to_string_lossy().into_owned(),
201 None => return Vec::new(),
202 };
203 let new_dir = match new_file.parent() {
204 Some(d) => d,
205 None => return Vec::new(),
206 };
207
208 let mut affected = Vec::new();
209
210 for entry in self.contexts_iter() {
211 let ctx_entry = entry.value();
212 let search = &ctx_entry.context.include_search;
213
214 for resolved_path in &ctx_entry.resolved_includes {
215 let resolved_name = match resolved_path.file_name() {
216 Some(n) => n.to_string_lossy(),
217 None => continue,
218 };
219
220 if *resolved_name != new_name {
221 continue;
222 }
223
224 let resolved_dir = match resolved_path.parent() {
225 Some(d) => d,
226 None => continue,
227 };
228
229 if resolved_dir == new_dir {
232 continue;
233 }
234
235 if is_higher_priority(new_dir, resolved_dir, search) {
236 affected.push(*entry.key());
237 break; }
239 }
240 }
241
242 affected
243 }
244
245 #[must_use]
248 pub fn check_new_resolve(&self, new_file: &Path) -> Vec<ContextKey> {
249 let new_name = match new_file.file_name() {
250 Some(n) => n.to_string_lossy().into_owned(),
251 None => return Vec::new(),
252 };
253
254 let mut affected = Vec::new();
255
256 for entry in self.contexts_iter() {
257 let ctx_entry = entry.value();
258
259 for unresolved in &ctx_entry.unresolved_includes {
260 let unresolved_name = Path::new(unresolved)
263 .file_name()
264 .map(|n| n.to_string_lossy().into_owned())
265 .unwrap_or_default();
266
267 if unresolved_name == new_name {
268 affected.push(*entry.key());
269 break;
270 }
271 }
272 }
273
274 affected
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::core::NormalizedPath;
282
283 use super::super::context::CompileContext;
284 use super::super::scanner::ScanResult;
285 use super::super::search_paths::IncludeSearchPaths;
286 use crate::hash::ContentHash;
287
288 fn dummy_hash(path: &Path) -> Option<ContentHash> {
289 Some(crate::hash::hash_bytes(path.to_string_lossy().as_bytes()))
290 }
291
292 fn make_ctx_with_search(source: &str, search: IncludeSearchPaths) -> CompileContext {
293 CompileContext {
294 source_file: NormalizedPath::from(source),
295 include_search: search,
296 defines: Vec::new(),
297 flags: Vec::new(),
298 force_includes: Vec::new(),
299 unknown_flags: Vec::new(),
300 }
301 }
302
303 #[test]
306 fn watch_set_from_paths_groups_by_dir() {
307 let ws = WatchSet::from_paths([
308 NormalizedPath::from("/inc/a.h"),
309 NormalizedPath::from("/inc/b.h"),
310 NormalizedPath::from("/src/main.c"),
311 ]);
312 assert_eq!(ws.dir_count(), 2);
313 assert_eq!(ws.file_count(), 3);
314 assert!(ws.is_watched(Path::new("/inc")));
315 assert!(ws.is_watched(Path::new("/src")));
316 }
317
318 #[test]
319 fn watch_set_deduplication() {
320 let ws = WatchSet::from_paths([
321 NormalizedPath::from("/inc/a.h"),
322 NormalizedPath::from("/inc/a.h"), ]);
324 assert_eq!(ws.dir_count(), 1);
325 assert_eq!(ws.file_count(), 1);
326 }
327
328 #[test]
329 fn watch_set_is_tracked() {
330 let ws = WatchSet::from_paths([NormalizedPath::from("/inc/a.h")]);
331 assert!(ws.is_tracked(Path::new("/inc/a.h")));
332 assert!(!ws.is_tracked(Path::new("/inc/b.h")));
333 assert!(!ws.is_tracked(Path::new("/other/a.h")));
334 }
335
336 #[cfg(windows)]
337 #[test]
338 fn watch_set_is_tracked_ignores_filename_case_on_windows() {
339 let ws = WatchSet::from_paths([NormalizedPath::from(r"C:\inc\Config.h")]);
340 assert!(ws.is_tracked(Path::new(r"C:\inc\config.h")));
341 assert!(ws.is_tracked(Path::new(r"C:\inc\CONFIG.H")));
342 }
343
344 #[test]
345 fn watch_set_add_dir_empty() {
346 let mut ws = WatchSet::new();
347 ws.add_dir(NormalizedPath::from("/usr/include"));
348 assert!(ws.is_watched(Path::new("/usr/include")));
349 assert_eq!(ws.file_count(), 0);
350 assert_eq!(ws.dir_count(), 1);
351 }
352
353 #[test]
354 fn watch_set_add_path() {
355 let mut ws = WatchSet::new();
356 ws.add_path(Path::new("/inc/foo.h"));
357 assert!(ws.is_tracked(Path::new("/inc/foo.h")));
358 assert!(ws.is_watched(Path::new("/inc")));
359 }
360
361 #[test]
362 fn watch_set_new_dirs_vs() {
363 let old = WatchSet::from_paths([NormalizedPath::from("/inc/a.h")]);
364 let new = WatchSet::from_paths([
365 NormalizedPath::from("/inc/a.h"),
366 NormalizedPath::from("/new/b.h"),
367 ]);
368 let added = new.new_dirs_vs(&old);
369 assert_eq!(added, vec![NormalizedPath::from("/new")]);
370 }
371
372 #[test]
373 fn watch_set_removed_dirs_vs() {
374 let old = WatchSet::from_paths([
375 NormalizedPath::from("/inc/a.h"),
376 NormalizedPath::from("/old/b.h"),
377 ]);
378 let new = WatchSet::from_paths([NormalizedPath::from("/inc/a.h")]);
379 let removed = new.removed_dirs_vs(&old);
380 assert_eq!(removed, vec![NormalizedPath::from("/old")]);
381 }
382
383 #[test]
386 fn watch_set_includes_source_and_headers() {
387 let graph = DepGraph::new();
388 let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
389 let key = graph.register(ctx);
390
391 let scan = ScanResult {
392 resolved: vec![
393 NormalizedPath::from("/inc/a.h"),
394 NormalizedPath::from("/inc/b.h"),
395 ],
396 unresolved: Vec::new(),
397 has_computed: false,
398 };
399 graph.update(&key, scan, dummy_hash);
400
401 let ws = graph.watch_set();
402 assert!(ws.is_tracked(Path::new("/src/main.c")));
403 assert!(ws.is_tracked(Path::new("/inc/a.h")));
404 assert!(ws.is_tracked(Path::new("/inc/b.h")));
405 }
406
407 #[test]
408 fn watch_set_includes_search_dirs() {
409 let graph = DepGraph::new();
410 let search = IncludeSearchPaths {
411 user: vec![NormalizedPath::from("/project/include")],
412 system: vec![NormalizedPath::from("/usr/include")],
413 ..Default::default()
414 };
415 let ctx = make_ctx_with_search("/src/main.c", search);
416 graph.register(ctx);
417
418 let ws = graph.watch_set();
419 assert!(ws.is_watched(Path::new("/project/include")));
421 assert!(ws.is_watched(Path::new("/usr/include")));
422 }
423
424 #[test]
425 fn watch_set_dedupes_across_contexts() {
426 let graph = DepGraph::new();
427 let search = IncludeSearchPaths {
428 user: vec![NormalizedPath::from("/inc")],
429 ..Default::default()
430 };
431
432 let ctx1 = make_ctx_with_search("/src/a.c", search.clone());
433 let key1 = graph.register(ctx1);
434 let ctx2 = make_ctx_with_search("/src/b.c", search);
435 let key2 = graph.register(ctx2);
436
437 let scan = ScanResult {
438 resolved: vec![NormalizedPath::from("/inc/common.h")],
439 unresolved: Vec::new(),
440 has_computed: false,
441 };
442 graph.update(&key1, scan.clone(), dummy_hash);
443 graph.update(&key2, scan, dummy_hash);
444
445 let ws = graph.watch_set();
446 let inc_count = ws
448 .dirs()
449 .filter(|d| d.as_path() == Path::new("/inc"))
450 .count();
451 assert_eq!(inc_count, 1);
452 }
453
454 #[test]
457 fn check_shadow_detects_higher_priority() {
458 let graph = DepGraph::new();
459 let search = IncludeSearchPaths {
460 user: vec![NormalizedPath::from("/high"), NormalizedPath::from("/low")],
461 ..Default::default()
462 };
463 let ctx = make_ctx_with_search("/src/main.c", search);
464 let key = graph.register(ctx);
465
466 let scan = ScanResult {
468 resolved: vec![NormalizedPath::from("/low/foo.h")],
469 unresolved: Vec::new(),
470 has_computed: false,
471 };
472 graph.update(&key, scan, dummy_hash);
473
474 let affected = graph.check_shadow(Path::new("/high/foo.h"));
476 assert_eq!(affected, vec![key]);
477 }
478
479 #[test]
480 fn check_shadow_no_false_positive_lower_priority() {
481 let graph = DepGraph::new();
482 let search = IncludeSearchPaths {
483 user: vec![NormalizedPath::from("/high"), NormalizedPath::from("/low")],
484 ..Default::default()
485 };
486 let ctx = make_ctx_with_search("/src/main.c", search);
487 let key = graph.register(ctx);
488
489 let scan = ScanResult {
491 resolved: vec![NormalizedPath::from("/high/foo.h")],
492 unresolved: Vec::new(),
493 has_computed: false,
494 };
495 graph.update(&key, scan, dummy_hash);
496
497 let affected = graph.check_shadow(Path::new("/low/foo.h"));
499 assert!(affected.is_empty());
500 }
501
502 #[test]
503 fn check_shadow_different_filename_no_match() {
504 let graph = DepGraph::new();
505 let search = IncludeSearchPaths {
506 user: vec![NormalizedPath::from("/high"), NormalizedPath::from("/low")],
507 ..Default::default()
508 };
509 let ctx = make_ctx_with_search("/src/main.c", search);
510 let key = graph.register(ctx);
511
512 let scan = ScanResult {
513 resolved: vec![NormalizedPath::from("/low/foo.h")],
514 unresolved: Vec::new(),
515 has_computed: false,
516 };
517 graph.update(&key, scan, dummy_hash);
518
519 let affected = graph.check_shadow(Path::new("/high/bar.h"));
521 assert!(affected.is_empty());
522 }
523
524 #[test]
525 fn check_shadow_same_dir_not_shadow() {
526 let graph = DepGraph::new();
527 let search = IncludeSearchPaths {
528 user: vec![NormalizedPath::from("/inc")],
529 ..Default::default()
530 };
531 let ctx = make_ctx_with_search("/src/main.c", search);
532 let key = graph.register(ctx);
533
534 let scan = ScanResult {
535 resolved: vec![NormalizedPath::from("/inc/foo.h")],
536 unresolved: Vec::new(),
537 has_computed: false,
538 };
539 graph.update(&key, scan, dummy_hash);
540
541 let affected = graph.check_shadow(Path::new("/inc/foo.h"));
543 assert!(affected.is_empty());
544 }
545
546 #[test]
547 fn check_shadow_iquote_over_user() {
548 let graph = DepGraph::new();
549 let search = IncludeSearchPaths {
550 iquote: vec![NormalizedPath::from("/iquote")],
551 user: vec![NormalizedPath::from("/user")],
552 ..Default::default()
553 };
554 let ctx = make_ctx_with_search("/src/main.c", search);
555 let key = graph.register(ctx);
556
557 let scan = ScanResult {
559 resolved: vec![NormalizedPath::from("/user/foo.h")],
560 unresolved: Vec::new(),
561 has_computed: false,
562 };
563 graph.update(&key, scan, dummy_hash);
564
565 let affected = graph.check_shadow(Path::new("/iquote/foo.h"));
567 assert_eq!(affected, vec![key]);
568 }
569
570 #[test]
571 fn check_shadow_cold_context_not_affected() {
572 let graph = DepGraph::new();
573 let search = IncludeSearchPaths {
574 user: vec![NormalizedPath::from("/high"), NormalizedPath::from("/low")],
575 ..Default::default()
576 };
577 let ctx = make_ctx_with_search("/src/main.c", search);
578 graph.register(ctx);
579
580 let affected = graph.check_shadow(Path::new("/high/foo.h"));
582 assert!(affected.is_empty());
583 }
584
585 #[test]
588 fn check_new_resolve_matches_unresolved() {
589 let graph = DepGraph::new();
590 let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
591 let key = graph.register(ctx);
592
593 let scan = ScanResult {
594 resolved: Vec::new(),
595 unresolved: vec!["missing.h".to_string()],
596 has_computed: false,
597 };
598 graph.update(&key, scan, dummy_hash);
599
600 let affected = graph.check_new_resolve(Path::new("/inc/missing.h"));
601 assert_eq!(affected, vec![key]);
602 }
603
604 #[test]
605 fn check_new_resolve_no_match() {
606 let graph = DepGraph::new();
607 let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
608 let key = graph.register(ctx);
609
610 let scan = ScanResult {
611 resolved: Vec::new(),
612 unresolved: vec!["missing.h".to_string()],
613 has_computed: false,
614 };
615 graph.update(&key, scan, dummy_hash);
616
617 let affected = graph.check_new_resolve(Path::new("/inc/other.h"));
618 assert!(affected.is_empty());
619 }
620
621 #[test]
622 fn check_new_resolve_path_include() {
623 let graph = DepGraph::new();
624 let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
625 let key = graph.register(ctx);
626
627 let scan = ScanResult {
629 resolved: Vec::new(),
630 unresolved: vec!["sub/missing.h".to_string()],
631 has_computed: false,
632 };
633 graph.update(&key, scan, dummy_hash);
634
635 let affected = graph.check_new_resolve(Path::new("/inc/sub/missing.h"));
637 assert_eq!(affected, vec![key]);
638 }
639
640 #[test]
643 fn mark_stale_changes_state() {
644 let graph = DepGraph::new();
645 let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
646 let key = graph.register(ctx);
647
648 let scan = ScanResult {
649 resolved: Vec::new(),
650 unresolved: Vec::new(),
651 has_computed: false,
652 };
653 graph.update(&key, scan, dummy_hash);
654 assert_eq!(
655 graph.get_state(&key),
656 Some(super::super::graph::ContextState::Warm)
657 );
658
659 assert!(graph.mark_stale(&key));
660 assert_eq!(
661 graph.get_state(&key),
662 Some(super::super::graph::ContextState::Stale)
663 );
664 }
665
666 #[test]
667 fn mark_stale_nonexistent_returns_false() {
668 let graph = DepGraph::new();
669 let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
670 let key = ctx.context_key();
671 assert!(!graph.mark_stale(&key));
672 }
673
674 #[test]
677 fn priority_iquote_before_user() {
678 let search = IncludeSearchPaths {
679 iquote: vec![NormalizedPath::from("/q")],
680 user: vec![NormalizedPath::from("/u")],
681 ..Default::default()
682 };
683 assert!(is_higher_priority(
684 Path::new("/q"),
685 Path::new("/u"),
686 &search
687 ));
688 assert!(!is_higher_priority(
689 Path::new("/u"),
690 Path::new("/q"),
691 &search
692 ));
693 }
694
695 #[test]
696 fn priority_user_before_system() {
697 let search = IncludeSearchPaths {
698 user: vec![NormalizedPath::from("/u")],
699 system: vec![NormalizedPath::from("/s")],
700 ..Default::default()
701 };
702 assert!(is_higher_priority(
703 Path::new("/u"),
704 Path::new("/s"),
705 &search
706 ));
707 }
708
709 #[test]
710 fn priority_unknown_dir_returns_false() {
711 let search = IncludeSearchPaths {
712 user: vec![NormalizedPath::from("/u")],
713 ..Default::default()
714 };
715 assert!(!is_higher_priority(
716 Path::new("/unknown"),
717 Path::new("/u"),
718 &search
719 ));
720 }
721
722 #[test]
723 fn priority_same_dir_returns_false() {
724 let search = IncludeSearchPaths {
725 user: vec![NormalizedPath::from("/u")],
726 ..Default::default()
727 };
728 assert!(!is_higher_priority(
729 Path::new("/u"),
730 Path::new("/u"),
731 &search
732 ));
733 }
734}