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;
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 },
449 file,
450 );
451 edge_store.add_edge(
452 id3,
453 id1,
454 EdgeKind::Calls {
455 argument_count: 0,
456 is_async: false,
457 },
458 file,
459 );
460
461 assert_eq!(edge_store.edges_from(id1).len(), 1);
463 assert_eq!(edge_store.edges_to(id1).len(), 1);
464
465 let mut cleanup = CascadeCleanup::new();
467 let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id1);
468
469 assert!(result.was_removed());
470 assert_eq!(result.edges_invalidated, 2); assert!(!arena.contains(id1));
474 assert_eq!(indices.len(), 2);
475
476 assert_eq!(cleanup.stats().nodes_removed, 1);
478 assert_eq!(cleanup.stats().edges_invalidated, 2);
479 }
480
481 #[test]
482 fn test_remove_node_with_metadata() {
483 let mut arena = NodeArena::new();
484 let mut indices = AuxiliaryIndices::new();
485 let edge_store = BidirectionalEdgeStore::new();
486
487 let file = test_file(1);
488 let name = test_name(1);
489 let entry = test_entry(NodeKind::Function, name, file);
490
491 let node_id = arena.alloc(entry).unwrap();
492 indices.add(node_id, NodeKind::Function, name, None, file);
493
494 let mut cleanup = CascadeCleanup::new();
495 let result = cleanup.remove_node_with_metadata(
496 &mut arena,
497 &mut indices,
498 &edge_store,
499 node_id,
500 NodeKind::Function,
501 name,
502 None,
503 file,
504 );
505
506 assert!(result.was_removed());
507 assert!(result.removed_from_indices);
508 assert!(!arena.contains(node_id));
509 assert_eq!(indices.len(), 0);
510 }
511
512 #[test]
513 fn test_remove_file() {
514 let mut arena = NodeArena::new();
515 let mut indices = AuxiliaryIndices::new();
516 let edge_store = BidirectionalEdgeStore::new();
517
518 let file1 = test_file(1);
519 let file2 = test_file(2);
520
521 let entry1 = test_entry(NodeKind::Function, test_name(1), file1);
523 let entry2 = test_entry(NodeKind::Class, test_name(2), file1);
524 let entry3 = test_entry(NodeKind::Method, test_name(3), file1);
525
526 let entry4 = test_entry(NodeKind::Function, test_name(4), file2);
528
529 let id1 = arena.alloc(entry1).unwrap();
530 let id2 = arena.alloc(entry2).unwrap();
531 let id3 = arena.alloc(entry3).unwrap();
532 let id4 = arena.alloc(entry4).unwrap();
533
534 indices.add(id1, NodeKind::Function, test_name(1), None, file1);
535 indices.add(id2, NodeKind::Class, test_name(2), None, file1);
536 indices.add(id3, NodeKind::Method, test_name(3), None, file1);
537 indices.add(id4, NodeKind::Function, test_name(4), None, file2);
538
539 edge_store.add_edge(
541 id1,
542 id2,
543 EdgeKind::Calls {
544 argument_count: 0,
545 is_async: false,
546 },
547 file1,
548 );
549 edge_store.add_edge(
550 id2,
551 id3,
552 EdgeKind::Calls {
553 argument_count: 0,
554 is_async: false,
555 },
556 file1,
557 );
558
559 assert_eq!(arena.len(), 4);
560 assert_eq!(indices.len(), 4);
561
562 let mut cleanup = CascadeCleanup::new();
564 let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file1);
565
566 assert_eq!(result.file, file1);
567 assert_eq!(result.nodes_removed, 3);
568
569 assert!(!arena.contains(id1));
571 assert!(!arena.contains(id2));
572 assert!(!arena.contains(id3));
573
574 assert!(arena.contains(id4));
576
577 assert_eq!(indices.len(), 1);
579 assert!(indices.by_file(file1).is_empty());
580 assert_eq!(indices.by_file(file2).len(), 1);
581
582 assert_eq!(cleanup.stats().nodes_removed, 3);
584 assert_eq!(cleanup.stats().files_processed, 1);
585 }
586
587 #[test]
588 fn test_remove_empty_file() {
589 let mut arena = NodeArena::new();
590 let mut indices = AuxiliaryIndices::new();
591 let edge_store = BidirectionalEdgeStore::new();
592
593 let file = test_file(1);
594
595 let mut cleanup = CascadeCleanup::new();
596 let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file);
597
598 assert_eq!(result.nodes_removed, 0);
599 assert_eq!(result.edges_invalidated, 0);
600 }
601
602 #[test]
603 fn test_convenience_cascade_remove_node() {
604 let mut arena = NodeArena::new();
605 let mut indices = AuxiliaryIndices::new();
606 let edge_store = BidirectionalEdgeStore::new();
607
608 let file = test_file(1);
609 let name = test_name(1);
610 let entry = test_entry(NodeKind::Function, name, file);
611
612 let node_id = arena.alloc(entry).unwrap();
613 indices.add(node_id, NodeKind::Function, name, None, file);
614
615 let result = cascade_remove_node(&mut arena, &mut indices, &edge_store, node_id);
616
617 assert!(result.was_removed());
618 assert!(!arena.contains(node_id));
619 }
620
621 #[test]
622 fn test_convenience_cascade_remove_file() {
623 let mut arena = NodeArena::new();
624 let mut indices = AuxiliaryIndices::new();
625 let edge_store = BidirectionalEdgeStore::new();
626
627 let file = test_file(1);
628
629 let entry = test_entry(NodeKind::Function, test_name(1), file);
630 let node_id = arena.alloc(entry).unwrap();
631 indices.add(node_id, NodeKind::Function, test_name(1), None, file);
632
633 let result = cascade_remove_file(&mut arena, &mut indices, &edge_store, file);
634
635 assert_eq!(result.nodes_removed, 1);
636 assert!(!arena.contains(node_id));
637 }
638
639 #[test]
640 fn test_reset_stats() {
641 let mut cleanup = CascadeCleanup::new();
642 let mut arena = NodeArena::new();
643 let mut indices = AuxiliaryIndices::new();
644 let edge_store = BidirectionalEdgeStore::new();
645
646 let file = test_file(1);
647 let entry = test_entry(NodeKind::Function, test_name(1), file);
648 let node_id = arena.alloc(entry).unwrap();
649 indices.add(node_id, NodeKind::Function, test_name(1), None, file);
650
651 cleanup.remove_node(&mut arena, &mut indices, &edge_store, node_id);
652
653 assert_eq!(cleanup.stats().nodes_removed, 1);
654
655 cleanup.reset_stats();
656
657 assert_eq!(cleanup.stats().nodes_removed, 0);
658 assert_eq!(cleanup.stats().edges_invalidated, 0);
659 }
660
661 #[test]
662 fn test_cascade_removal_result_methods() {
663 let result_with_entry = CascadeRemovalResult {
664 node_id: NodeId::new(1, 1),
665 entry: Some(NodeEntry::new(
666 NodeKind::Function,
667 StringId::new(1),
668 FileId::new(1),
669 )),
670 edges_invalidated: 5,
671 removed_from_indices: true,
672 };
673 assert!(result_with_entry.was_removed());
674
675 let result_without_entry = CascadeRemovalResult {
676 node_id: NodeId::new(2, 1),
677 entry: None,
678 edges_invalidated: 0,
679 removed_from_indices: false,
680 };
681 assert!(!result_without_entry.was_removed());
682 }
683
684 #[test]
685 fn test_no_dangling_edges_after_removal() {
686 let mut arena = NodeArena::new();
688 let mut indices = AuxiliaryIndices::new();
689 let edge_store = BidirectionalEdgeStore::new();
690
691 let file = test_file(1);
692
693 let entry_a = test_entry(NodeKind::Function, test_name(1), file);
695 let entry_b = test_entry(NodeKind::Function, test_name(2), file);
696 let entry_c = test_entry(NodeKind::Function, test_name(3), file);
697
698 let id_a = arena.alloc(entry_a).unwrap();
699 let id_b = arena.alloc(entry_b).unwrap();
700 let id_c = arena.alloc(entry_c).unwrap();
701
702 indices.add(id_a, NodeKind::Function, test_name(1), None, file);
703 indices.add(id_b, NodeKind::Function, test_name(2), None, file);
704 indices.add(id_c, NodeKind::Function, test_name(3), None, file);
705
706 edge_store.add_edge(
707 id_a,
708 id_b,
709 EdgeKind::Calls {
710 argument_count: 0,
711 is_async: false,
712 },
713 file,
714 );
715 edge_store.add_edge(
716 id_b,
717 id_c,
718 EdgeKind::Calls {
719 argument_count: 0,
720 is_async: false,
721 },
722 file,
723 );
724 edge_store.add_edge(
725 id_a,
726 id_c,
727 EdgeKind::Calls {
728 argument_count: 0,
729 is_async: false,
730 },
731 file,
732 );
733
734 let mut cleanup = CascadeCleanup::new();
736 let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id_b);
737
738 assert!(result.was_removed());
740 assert!(!arena.contains(id_b));
741
742 assert!(arena.contains(id_a));
744 assert!(arena.contains(id_c));
745
746 assert_eq!(result.edges_invalidated, 2); let edges_from_a = edge_store.edges_from(id_a);
753 assert_eq!(
754 edges_from_a.len(),
755 1,
756 "A should have exactly 1 visible edge (A->C)"
757 );
758 assert_eq!(
759 edges_from_a[0].target, id_c,
760 "A's edge should point to C, not B"
761 );
762
763 let edges_to_b = edge_store.edges_to(id_b);
765 assert!(
766 edges_to_b.is_empty(),
767 "No edges should be visible to removed node B"
768 );
769
770 let edges_from_b = edge_store.edges_from(id_b);
772 assert!(
773 edges_from_b.is_empty(),
774 "No edges should be visible from removed node B"
775 );
776
777 let edges_to_c = edge_store.edges_to(id_c);
779 assert_eq!(
780 edges_to_c.len(),
781 1,
782 "C should have exactly 1 incoming visible edge"
783 );
784 assert_eq!(
785 edges_to_c[0].source, id_a,
786 "C's incoming edge should be from A, not B"
787 );
788 }
789
790 #[test]
791 fn test_cross_file_edges_removed_with_file() {
792 let mut arena = NodeArena::new();
795 let mut indices = AuxiliaryIndices::new();
796 let edge_store = BidirectionalEdgeStore::new();
797
798 let file_a = test_file(1);
799 let file_b = test_file(2);
800
801 let entry_a = test_entry(NodeKind::Function, test_name(1), file_a);
803 let id_a = arena.alloc(entry_a).unwrap();
804 indices.add(id_a, NodeKind::Function, test_name(1), None, file_a);
805
806 let entry_b = test_entry(NodeKind::Function, test_name(2), file_b);
808 let id_b = arena.alloc(entry_b).unwrap();
809 indices.add(id_b, NodeKind::Function, test_name(2), None, file_b);
810
811 edge_store.add_edge(
814 id_a,
815 id_b,
816 EdgeKind::Calls {
817 argument_count: 0,
818 is_async: false,
819 },
820 file_a,
821 );
822
823 assert_eq!(edge_store.edges_from(id_a).len(), 1);
825 assert_eq!(edge_store.edges_to(id_b).len(), 1);
826
827 let mut cleanup = CascadeCleanup::new();
829 let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file_b);
830
831 assert_eq!(result.nodes_removed, 1);
832 assert!(!arena.contains(id_b));
833 assert!(arena.contains(id_a));
834
835 let edges_from_a = edge_store.edges_from(id_a);
839 assert!(
840 edges_from_a.is_empty(),
841 "Cross-file edge A->B should be invisible after B's file removal"
842 );
843
844 let edges_to_b = edge_store.edges_to(id_b);
845 assert!(
846 edges_to_b.is_empty(),
847 "No edges should be visible to removed node B"
848 );
849 }
850
851 #[test]
852 fn test_delta_only_edge_removal_with_matching_generation() {
853 let mut arena = NodeArena::new();
856 let mut indices = AuxiliaryIndices::new();
857 let edge_store = BidirectionalEdgeStore::new();
858
859 let file = test_file(1);
860
861 let entry_a = test_entry(NodeKind::Function, test_name(1), file);
863 let entry_b = test_entry(NodeKind::Function, test_name(2), file);
864
865 let id_a = arena.alloc(entry_a).unwrap();
866 let id_b = arena.alloc(entry_b).unwrap();
867
868 indices.add(id_a, NodeKind::Function, test_name(1), None, file);
869 indices.add(id_b, NodeKind::Function, test_name(2), None, file);
870
871 assert!(
873 id_a.generation() > 0,
874 "Node A should have non-zero generation"
875 );
876 assert!(
877 id_b.generation() > 0,
878 "Node B should have non-zero generation"
879 );
880
881 edge_store.add_edge(
883 id_a,
884 id_b,
885 EdgeKind::Calls {
886 argument_count: 0,
887 is_async: false,
888 },
889 file,
890 );
891
892 let edges_before = edge_store.edges_from(id_a);
894 assert_eq!(edges_before.len(), 1);
895 assert_eq!(edges_before[0].target, id_b);
896
897 let mut cleanup = CascadeCleanup::new();
899 let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id_b);
900
901 assert!(result.was_removed());
902 assert_eq!(result.edges_invalidated, 1);
903
904 let edges_after = edge_store.edges_from(id_a);
908 assert!(
909 edges_after.is_empty(),
910 "Delta-only edge A->B should be invisible after B removal"
911 );
912
913 let edges_to_b = edge_store.edges_to(id_b);
914 assert!(
915 edges_to_b.is_empty(),
916 "No edges should be visible to removed node B"
917 );
918 }
919}