1use crate::graph::unified::edge::bidirectional::BidirectionalEdgeStore;
28use crate::graph::unified::file::FileId;
29use crate::graph::unified::node::id::NodeId;
30use crate::graph::unified::node::kind::NodeKind;
31use crate::graph::unified::storage::arena::{NodeArena, NodeEntry};
32use crate::graph::unified::storage::indices::AuxiliaryIndices;
33use crate::graph::unified::string::StringId;
34
35#[derive(Debug, Clone)]
37pub struct CascadeRemovalResult {
38 pub node_id: NodeId,
40 pub entry: Option<NodeEntry>,
42 pub edges_invalidated: usize,
44 pub removed_from_indices: bool,
46}
47
48impl CascadeRemovalResult {
49 #[inline]
51 #[must_use]
52 pub fn was_removed(&self) -> bool {
53 self.entry.is_some()
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct FileCascadeResult {
60 pub file: FileId,
62 pub nodes_removed: usize,
64 pub edges_invalidated: usize,
66}
67
68#[derive(Debug, Default)]
80pub struct CascadeCleanup {
81 stats: CascadeStats,
83}
84
85#[derive(Debug, Clone, Copy, Default)]
87pub struct CascadeStats {
88 pub nodes_removed: u64,
90 pub edges_invalidated: u64,
92 pub files_processed: u64,
94}
95
96impl CascadeCleanup {
97 #[must_use]
99 pub fn new() -> Self {
100 Self {
101 stats: CascadeStats::default(),
102 }
103 }
104
105 #[must_use]
107 pub fn stats(&self) -> CascadeStats {
108 self.stats
109 }
110
111 pub fn reset_stats(&mut self) {
113 self.stats = CascadeStats::default();
114 }
115
116 pub fn remove_node(
135 &mut self,
136 arena: &mut NodeArena,
137 indices: &mut AuxiliaryIndices,
138 edge_store: &BidirectionalEdgeStore,
139 node_id: NodeId,
140 ) -> CascadeRemovalResult {
141 let entry = arena.remove(node_id);
143
144 let removed_from_indices = if let Some(ref e) = entry {
146 indices.remove(node_id, e.kind, e.name, e.qualified_name, e.file)
147 } else {
148 false
149 };
150
151 let edges_invalidated = Self::invalidate_node_edges(edge_store, node_id, entry.as_ref());
153
154 if entry.is_some() {
156 self.stats.nodes_removed += 1;
157 }
158 self.stats.edges_invalidated += edges_invalidated as u64;
159
160 CascadeRemovalResult {
161 node_id,
162 entry,
163 edges_invalidated,
164 removed_from_indices,
165 }
166 }
167
168 #[allow(clippy::too_many_arguments)]
187 pub fn remove_node_with_metadata(
188 &mut self,
189 arena: &mut NodeArena,
190 indices: &mut AuxiliaryIndices,
191 edge_store: &BidirectionalEdgeStore,
192 node_id: NodeId,
193 kind: NodeKind,
194 name: StringId,
195 qualified_name: Option<StringId>,
196 file: FileId,
197 ) -> CascadeRemovalResult {
198 let entry = arena.remove(node_id);
200
201 let removed_from_indices = indices.remove(node_id, kind, name, qualified_name, file);
203
204 let edges_invalidated = Self::invalidate_node_edges(edge_store, node_id, entry.as_ref());
206
207 if entry.is_some() {
209 self.stats.nodes_removed += 1;
210 }
211 self.stats.edges_invalidated += edges_invalidated as u64;
212
213 CascadeRemovalResult {
214 node_id,
215 entry,
216 edges_invalidated,
217 removed_from_indices,
218 }
219 }
220
221 pub fn remove_file(
237 &mut self,
238 arena: &mut NodeArena,
239 indices: &mut AuxiliaryIndices,
240 edge_store: &BidirectionalEdgeStore,
241 file: FileId,
242 ) -> FileCascadeResult {
243 let node_ids: Vec<NodeId> = indices.by_file(file).to_vec();
245
246 let nodes_metadata: Vec<_> = node_ids
248 .iter()
249 .filter_map(|&id| {
250 arena
251 .get(id)
252 .map(|e| (id, e.kind, e.name, e.qualified_name))
253 })
254 .collect();
255
256 let _indices_removed = indices.remove_file_with_info(file, nodes_metadata.iter().copied());
258
259 let mut nodes_removed = 0;
261 let mut edges_invalidated = 0;
262
263 for &node_id in &node_ids {
264 let entry = arena.remove(node_id);
266
267 if entry.is_some() {
268 nodes_removed += 1;
269 }
270
271 edges_invalidated += Self::invalidate_node_edges(edge_store, node_id, entry.as_ref());
273 }
274
275 let file_edges_cleared = edge_store.clear_file(file);
277 edges_invalidated += file_edges_cleared;
278
279 self.stats.nodes_removed += nodes_removed as u64;
281 self.stats.edges_invalidated += edges_invalidated as u64;
282 self.stats.files_processed += 1;
283
284 FileCascadeResult {
285 file,
286 nodes_removed,
287 edges_invalidated,
288 }
289 }
290
291 fn invalidate_node_edges(
296 edge_store: &BidirectionalEdgeStore,
297 node_id: NodeId,
298 entry: Option<&NodeEntry>,
299 ) -> usize {
300 let file = entry.map_or(FileId::new(0), |e| e.file);
302
303 let mut count = 0;
304
305 let outgoing = edge_store.edges_from(node_id);
307 for edge in outgoing {
308 edge_store.remove_edge(node_id, edge.target, edge.kind.clone(), file);
309 count += 1;
310 }
311
312 let incoming = edge_store.edges_to(node_id);
316 for edge in incoming {
317 edge_store.remove_edge(edge.source, node_id, edge.kind.clone(), edge.file);
319 count += 1;
320 }
321
322 count
323 }
324}
325
326pub fn cascade_remove_node(
331 arena: &mut NodeArena,
332 indices: &mut AuxiliaryIndices,
333 edge_store: &BidirectionalEdgeStore,
334 node_id: NodeId,
335) -> CascadeRemovalResult {
336 let mut cleanup = CascadeCleanup::new();
337 cleanup.remove_node(arena, indices, edge_store, node_id)
338}
339
340pub fn cascade_remove_file(
345 arena: &mut NodeArena,
346 indices: &mut AuxiliaryIndices,
347 edge_store: &BidirectionalEdgeStore,
348 file: FileId,
349) -> FileCascadeResult {
350 let mut cleanup = CascadeCleanup::new();
351 cleanup.remove_file(arena, indices, edge_store, file)
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::graph::unified::edge::kind::{EdgeKind, ResolvedVia};
358
359 fn test_file(id: u32) -> FileId {
360 FileId::new(id)
361 }
362
363 fn test_name(id: u32) -> StringId {
364 StringId::new(id)
365 }
366
367 fn test_entry(kind: NodeKind, name: StringId, file: FileId) -> NodeEntry {
368 NodeEntry::new(kind, name, file)
369 }
370
371 #[test]
372 fn test_cascade_cleanup_new() {
373 let cleanup = CascadeCleanup::new();
374 assert_eq!(cleanup.stats().nodes_removed, 0);
375 assert_eq!(cleanup.stats().edges_invalidated, 0);
376 }
377
378 #[test]
379 fn test_remove_single_node() {
380 let mut arena = NodeArena::new();
381 let mut indices = AuxiliaryIndices::new();
382 let edge_store = BidirectionalEdgeStore::new();
383
384 let file = test_file(1);
385 let name = test_name(1);
386 let entry = test_entry(NodeKind::Function, name, file);
387
388 let node_id = arena.alloc(entry).unwrap();
389 indices.add(node_id, NodeKind::Function, name, None, file);
390
391 assert!(arena.contains(node_id));
392 assert_eq!(indices.len(), 1);
393
394 let mut cleanup = CascadeCleanup::new();
395 let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, node_id);
396
397 assert!(result.was_removed());
398 assert!(!arena.contains(node_id));
399 assert_eq!(indices.len(), 0);
400 assert_eq!(cleanup.stats().nodes_removed, 1);
401 }
402
403 #[test]
404 fn test_remove_nonexistent_node() {
405 let mut arena = NodeArena::new();
406 let mut indices = AuxiliaryIndices::new();
407 let edge_store = BidirectionalEdgeStore::new();
408
409 let fake_id = NodeId::new(999, 1);
410
411 let mut cleanup = CascadeCleanup::new();
412 let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, fake_id);
413
414 assert!(!result.was_removed());
415 assert_eq!(result.entry, None);
416 assert!(!result.removed_from_indices);
417 assert_eq!(cleanup.stats().nodes_removed, 0);
418 }
419
420 #[test]
421 fn test_remove_node_with_edges() {
422 let mut arena = NodeArena::new();
423 let mut indices = AuxiliaryIndices::new();
424 let edge_store = BidirectionalEdgeStore::new();
425
426 let file = test_file(1);
427
428 let entry1 = test_entry(NodeKind::Function, test_name(1), file);
430 let entry2 = test_entry(NodeKind::Function, test_name(2), file);
431 let entry3 = test_entry(NodeKind::Function, test_name(3), file);
432
433 let id1 = arena.alloc(entry1).unwrap();
434 let id2 = arena.alloc(entry2).unwrap();
435 let id3 = arena.alloc(entry3).unwrap();
436
437 indices.add(id1, NodeKind::Function, test_name(1), None, file);
438 indices.add(id2, NodeKind::Function, test_name(2), None, file);
439 indices.add(id3, NodeKind::Function, test_name(3), None, file);
440
441 edge_store.add_edge(
443 id1,
444 id2,
445 EdgeKind::Calls {
446 argument_count: 0,
447 is_async: false,
448 resolved_via: ResolvedVia::Direct,
449 },
450 file,
451 );
452 edge_store.add_edge(
453 id3,
454 id1,
455 EdgeKind::Calls {
456 argument_count: 0,
457 is_async: false,
458 resolved_via: ResolvedVia::Direct,
459 },
460 file,
461 );
462
463 assert_eq!(edge_store.edges_from(id1).len(), 1);
465 assert_eq!(edge_store.edges_to(id1).len(), 1);
466
467 let mut cleanup = CascadeCleanup::new();
469 let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id1);
470
471 assert!(result.was_removed());
472 assert_eq!(result.edges_invalidated, 2); assert!(!arena.contains(id1));
476 assert_eq!(indices.len(), 2);
477
478 assert_eq!(cleanup.stats().nodes_removed, 1);
480 assert_eq!(cleanup.stats().edges_invalidated, 2);
481 }
482
483 #[test]
484 fn test_remove_node_with_metadata() {
485 let mut arena = NodeArena::new();
486 let mut indices = AuxiliaryIndices::new();
487 let edge_store = BidirectionalEdgeStore::new();
488
489 let file = test_file(1);
490 let name = test_name(1);
491 let entry = test_entry(NodeKind::Function, name, file);
492
493 let node_id = arena.alloc(entry).unwrap();
494 indices.add(node_id, NodeKind::Function, name, None, file);
495
496 let mut cleanup = CascadeCleanup::new();
497 let result = cleanup.remove_node_with_metadata(
498 &mut arena,
499 &mut indices,
500 &edge_store,
501 node_id,
502 NodeKind::Function,
503 name,
504 None,
505 file,
506 );
507
508 assert!(result.was_removed());
509 assert!(result.removed_from_indices);
510 assert!(!arena.contains(node_id));
511 assert_eq!(indices.len(), 0);
512 }
513
514 #[test]
515 fn test_remove_file() {
516 let mut arena = NodeArena::new();
517 let mut indices = AuxiliaryIndices::new();
518 let edge_store = BidirectionalEdgeStore::new();
519
520 let file1 = test_file(1);
521 let file2 = test_file(2);
522
523 let entry1 = test_entry(NodeKind::Function, test_name(1), file1);
525 let entry2 = test_entry(NodeKind::Class, test_name(2), file1);
526 let entry3 = test_entry(NodeKind::Method, test_name(3), file1);
527
528 let entry4 = test_entry(NodeKind::Function, test_name(4), file2);
530
531 let id1 = arena.alloc(entry1).unwrap();
532 let id2 = arena.alloc(entry2).unwrap();
533 let id3 = arena.alloc(entry3).unwrap();
534 let id4 = arena.alloc(entry4).unwrap();
535
536 indices.add(id1, NodeKind::Function, test_name(1), None, file1);
537 indices.add(id2, NodeKind::Class, test_name(2), None, file1);
538 indices.add(id3, NodeKind::Method, test_name(3), None, file1);
539 indices.add(id4, NodeKind::Function, test_name(4), None, file2);
540
541 edge_store.add_edge(
543 id1,
544 id2,
545 EdgeKind::Calls {
546 argument_count: 0,
547 is_async: false,
548 resolved_via: ResolvedVia::Direct,
549 },
550 file1,
551 );
552 edge_store.add_edge(
553 id2,
554 id3,
555 EdgeKind::Calls {
556 argument_count: 0,
557 is_async: false,
558 resolved_via: ResolvedVia::Direct,
559 },
560 file1,
561 );
562
563 assert_eq!(arena.len(), 4);
564 assert_eq!(indices.len(), 4);
565
566 let mut cleanup = CascadeCleanup::new();
568 let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file1);
569
570 assert_eq!(result.file, file1);
571 assert_eq!(result.nodes_removed, 3);
572
573 assert!(!arena.contains(id1));
575 assert!(!arena.contains(id2));
576 assert!(!arena.contains(id3));
577
578 assert!(arena.contains(id4));
580
581 assert_eq!(indices.len(), 1);
583 assert!(indices.by_file(file1).is_empty());
584 assert_eq!(indices.by_file(file2).len(), 1);
585
586 assert_eq!(cleanup.stats().nodes_removed, 3);
588 assert_eq!(cleanup.stats().files_processed, 1);
589 }
590
591 #[test]
592 fn test_remove_empty_file() {
593 let mut arena = NodeArena::new();
594 let mut indices = AuxiliaryIndices::new();
595 let edge_store = BidirectionalEdgeStore::new();
596
597 let file = test_file(1);
598
599 let mut cleanup = CascadeCleanup::new();
600 let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file);
601
602 assert_eq!(result.nodes_removed, 0);
603 assert_eq!(result.edges_invalidated, 0);
604 }
605
606 #[test]
607 fn test_convenience_cascade_remove_node() {
608 let mut arena = NodeArena::new();
609 let mut indices = AuxiliaryIndices::new();
610 let edge_store = BidirectionalEdgeStore::new();
611
612 let file = test_file(1);
613 let name = test_name(1);
614 let entry = test_entry(NodeKind::Function, name, file);
615
616 let node_id = arena.alloc(entry).unwrap();
617 indices.add(node_id, NodeKind::Function, name, None, file);
618
619 let result = cascade_remove_node(&mut arena, &mut indices, &edge_store, node_id);
620
621 assert!(result.was_removed());
622 assert!(!arena.contains(node_id));
623 }
624
625 #[test]
626 fn test_convenience_cascade_remove_file() {
627 let mut arena = NodeArena::new();
628 let mut indices = AuxiliaryIndices::new();
629 let edge_store = BidirectionalEdgeStore::new();
630
631 let file = test_file(1);
632
633 let entry = test_entry(NodeKind::Function, test_name(1), file);
634 let node_id = arena.alloc(entry).unwrap();
635 indices.add(node_id, NodeKind::Function, test_name(1), None, file);
636
637 let result = cascade_remove_file(&mut arena, &mut indices, &edge_store, file);
638
639 assert_eq!(result.nodes_removed, 1);
640 assert!(!arena.contains(node_id));
641 }
642
643 #[test]
644 fn test_reset_stats() {
645 let mut cleanup = CascadeCleanup::new();
646 let mut arena = NodeArena::new();
647 let mut indices = AuxiliaryIndices::new();
648 let edge_store = BidirectionalEdgeStore::new();
649
650 let file = test_file(1);
651 let entry = test_entry(NodeKind::Function, test_name(1), file);
652 let node_id = arena.alloc(entry).unwrap();
653 indices.add(node_id, NodeKind::Function, test_name(1), None, file);
654
655 cleanup.remove_node(&mut arena, &mut indices, &edge_store, node_id);
656
657 assert_eq!(cleanup.stats().nodes_removed, 1);
658
659 cleanup.reset_stats();
660
661 assert_eq!(cleanup.stats().nodes_removed, 0);
662 assert_eq!(cleanup.stats().edges_invalidated, 0);
663 }
664
665 #[test]
666 fn test_cascade_removal_result_methods() {
667 let result_with_entry = CascadeRemovalResult {
668 node_id: NodeId::new(1, 1),
669 entry: Some(NodeEntry::new(
670 NodeKind::Function,
671 StringId::new(1),
672 FileId::new(1),
673 )),
674 edges_invalidated: 5,
675 removed_from_indices: true,
676 };
677 assert!(result_with_entry.was_removed());
678
679 let result_without_entry = CascadeRemovalResult {
680 node_id: NodeId::new(2, 1),
681 entry: None,
682 edges_invalidated: 0,
683 removed_from_indices: false,
684 };
685 assert!(!result_without_entry.was_removed());
686 }
687
688 #[test]
689 fn test_no_dangling_edges_after_removal() {
690 let mut arena = NodeArena::new();
692 let mut indices = AuxiliaryIndices::new();
693 let edge_store = BidirectionalEdgeStore::new();
694
695 let file = test_file(1);
696
697 let entry_a = test_entry(NodeKind::Function, test_name(1), file);
699 let entry_b = test_entry(NodeKind::Function, test_name(2), file);
700 let entry_c = test_entry(NodeKind::Function, test_name(3), file);
701
702 let id_a = arena.alloc(entry_a).unwrap();
703 let id_b = arena.alloc(entry_b).unwrap();
704 let id_c = arena.alloc(entry_c).unwrap();
705
706 indices.add(id_a, NodeKind::Function, test_name(1), None, file);
707 indices.add(id_b, NodeKind::Function, test_name(2), None, file);
708 indices.add(id_c, NodeKind::Function, test_name(3), None, file);
709
710 edge_store.add_edge(
711 id_a,
712 id_b,
713 EdgeKind::Calls {
714 argument_count: 0,
715 is_async: false,
716 resolved_via: ResolvedVia::Direct,
717 },
718 file,
719 );
720 edge_store.add_edge(
721 id_b,
722 id_c,
723 EdgeKind::Calls {
724 argument_count: 0,
725 is_async: false,
726 resolved_via: ResolvedVia::Direct,
727 },
728 file,
729 );
730 edge_store.add_edge(
731 id_a,
732 id_c,
733 EdgeKind::Calls {
734 argument_count: 0,
735 is_async: false,
736 resolved_via: ResolvedVia::Direct,
737 },
738 file,
739 );
740
741 let mut cleanup = CascadeCleanup::new();
743 let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id_b);
744
745 assert!(result.was_removed());
747 assert!(!arena.contains(id_b));
748
749 assert!(arena.contains(id_a));
751 assert!(arena.contains(id_c));
752
753 assert_eq!(result.edges_invalidated, 2); let edges_from_a = edge_store.edges_from(id_a);
760 assert_eq!(
761 edges_from_a.len(),
762 1,
763 "A should have exactly 1 visible edge (A->C)"
764 );
765 assert_eq!(
766 edges_from_a[0].target, id_c,
767 "A's edge should point to C, not B"
768 );
769
770 let edges_to_b = edge_store.edges_to(id_b);
772 assert!(
773 edges_to_b.is_empty(),
774 "No edges should be visible to removed node B"
775 );
776
777 let edges_from_b = edge_store.edges_from(id_b);
779 assert!(
780 edges_from_b.is_empty(),
781 "No edges should be visible from removed node B"
782 );
783
784 let edges_to_c = edge_store.edges_to(id_c);
786 assert_eq!(
787 edges_to_c.len(),
788 1,
789 "C should have exactly 1 incoming visible edge"
790 );
791 assert_eq!(
792 edges_to_c[0].source, id_a,
793 "C's incoming edge should be from A, not B"
794 );
795 }
796
797 #[test]
798 fn test_cross_file_edges_removed_with_file() {
799 let mut arena = NodeArena::new();
802 let mut indices = AuxiliaryIndices::new();
803 let edge_store = BidirectionalEdgeStore::new();
804
805 let file_a = test_file(1);
806 let file_b = test_file(2);
807
808 let entry_a = test_entry(NodeKind::Function, test_name(1), file_a);
810 let id_a = arena.alloc(entry_a).unwrap();
811 indices.add(id_a, NodeKind::Function, test_name(1), None, file_a);
812
813 let entry_b = test_entry(NodeKind::Function, test_name(2), file_b);
815 let id_b = arena.alloc(entry_b).unwrap();
816 indices.add(id_b, NodeKind::Function, test_name(2), None, file_b);
817
818 edge_store.add_edge(
821 id_a,
822 id_b,
823 EdgeKind::Calls {
824 argument_count: 0,
825 is_async: false,
826 resolved_via: ResolvedVia::Direct,
827 },
828 file_a,
829 );
830
831 assert_eq!(edge_store.edges_from(id_a).len(), 1);
833 assert_eq!(edge_store.edges_to(id_b).len(), 1);
834
835 let mut cleanup = CascadeCleanup::new();
837 let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file_b);
838
839 assert_eq!(result.nodes_removed, 1);
840 assert!(!arena.contains(id_b));
841 assert!(arena.contains(id_a));
842
843 let edges_from_a = edge_store.edges_from(id_a);
847 assert!(
848 edges_from_a.is_empty(),
849 "Cross-file edge A->B should be invisible after B's file removal"
850 );
851
852 let edges_to_b = edge_store.edges_to(id_b);
853 assert!(
854 edges_to_b.is_empty(),
855 "No edges should be visible to removed node B"
856 );
857 }
858
859 #[test]
860 fn test_delta_only_edge_removal_with_matching_generation() {
861 let mut arena = NodeArena::new();
864 let mut indices = AuxiliaryIndices::new();
865 let edge_store = BidirectionalEdgeStore::new();
866
867 let file = test_file(1);
868
869 let entry_a = test_entry(NodeKind::Function, test_name(1), file);
871 let entry_b = test_entry(NodeKind::Function, test_name(2), file);
872
873 let id_a = arena.alloc(entry_a).unwrap();
874 let id_b = arena.alloc(entry_b).unwrap();
875
876 indices.add(id_a, NodeKind::Function, test_name(1), None, file);
877 indices.add(id_b, NodeKind::Function, test_name(2), None, file);
878
879 assert!(
881 id_a.generation() > 0,
882 "Node A should have non-zero generation"
883 );
884 assert!(
885 id_b.generation() > 0,
886 "Node B should have non-zero generation"
887 );
888
889 edge_store.add_edge(
891 id_a,
892 id_b,
893 EdgeKind::Calls {
894 argument_count: 0,
895 is_async: false,
896 resolved_via: ResolvedVia::Direct,
897 },
898 file,
899 );
900
901 let edges_before = edge_store.edges_from(id_a);
903 assert_eq!(edges_before.len(), 1);
904 assert_eq!(edges_before[0].target, id_b);
905
906 let mut cleanup = CascadeCleanup::new();
908 let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id_b);
909
910 assert!(result.was_removed());
911 assert_eq!(result.edges_invalidated, 1);
912
913 let edges_after = edge_store.edges_from(id_a);
917 assert!(
918 edges_after.is_empty(),
919 "Delta-only edge A->B should be invisible after B removal"
920 );
921
922 let edges_to_b = edge_store.edges_to(id_b);
923 assert!(
924 edges_to_b.is_empty(),
925 "No edges should be visible to removed node B"
926 );
927 }
928}