1use crate::graph::{CycleGroup, CycleKind, EdgeKind, Graph};
2use crate::snapshot::PluginGraphs;
3use std::collections::HashMap;
4
5pub fn annotate_all_cycles(graphs: &mut PluginGraphs) {
8 annotate_graph_cycles(&mut graphs.files);
9}
10
11fn annotate_graph_cycles(graph: &mut Graph) {
12 let n = graph.nodes.len();
13 if n == 0 {
14 return;
15 }
16
17 let id_to_idx: HashMap<&str, usize> = graph
19 .nodes
20 .iter()
21 .enumerate()
22 .map(|(i, node)| (node.id.as_str(), i))
23 .collect();
24
25 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
31 for edge in &graph.edges {
32 if edge.kind == EdgeKind::Contains {
33 continue;
34 }
35 if let (Some(&fi), Some(&ti)) = (
36 id_to_idx.get(edge.from.as_str()),
37 id_to_idx.get(edge.to.as_str()),
38 ) && fi != ti
39 {
40 adj[fi].push(ti);
41 }
42 }
43
44 let sccs = kosaraju_sccs(n, &adj);
45
46 let mut node_kind: Vec<Option<CycleKind>> = vec![None; n];
47 let mut cycle_groups: Vec<CycleGroup> = Vec::new();
48
49 for scc in &sccs {
50 if scc.len() < 2 {
51 continue;
52 }
53 let kind = classify_scc(scc, graph);
54 for &idx in scc {
55 node_kind[idx] = Some(kind);
56 }
57 cycle_groups.push(CycleGroup {
58 kind,
59 nodes: scc.iter().map(|&i| graph.nodes[i].id.clone()).collect(),
60 });
61 }
62
63 for (i, node) in graph.nodes.iter_mut().enumerate() {
64 node.cycle_kind = node_kind[i];
65 }
66 graph.cycles = cycle_groups;
67}
68
69fn classify_scc(scc: &[usize], graph: &Graph) -> CycleKind {
70 if scc.iter().any(|&i| is_test_node(graph, i)) {
71 return CycleKind::TestEmbed;
72 }
73 if scc.len() == 2 {
74 CycleKind::Mutual
75 } else {
76 CycleKind::Chain
77 }
78}
79
80fn is_test_node(graph: &Graph, idx: usize) -> bool {
81 let node = &graph.nodes[idx];
82 let mut name = node.name.to_ascii_lowercase();
83 for ext in [".rs", ".py", ".ts", ".tsx", ".js", ".jsx"] {
85 if let Some(stem) = name.strip_suffix(ext) {
86 name = stem.to_string();
87 break;
88 }
89 }
90 if matches!(name.as_str(), "tests" | "test" | "benches" | "bench") {
92 return true;
93 }
94 if name.ends_with("_tests")
95 || name.ends_with("_test")
96 || name.ends_with("_bench")
97 || name.starts_with("test_")
98 {
99 return true;
100 }
101 let id = &node.id;
103 id.contains("::tests")
104 || id.contains("::test::")
105 || id.ends_with("::test")
106 || id.contains("/tests/")
107 || id.contains("/__tests__/")
108}
109
110fn kosaraju_sccs(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
115 let mut visited = vec![false; n];
117 let mut finish_order = Vec::with_capacity(n);
118 for i in 0..n {
119 if !visited[i] {
120 dfs_finish(i, adj, &mut visited, &mut finish_order);
121 }
122 }
123
124 let mut radj: Vec<Vec<usize>> = vec![Vec::new(); n];
126 for (u, neighbors) in adj.iter().enumerate() {
127 for &v in neighbors {
128 radj[v].push(u);
129 }
130 }
131
132 let mut visited2 = vec![false; n];
134 let mut sccs: Vec<Vec<usize>> = Vec::new();
135 for &start in finish_order.iter().rev() {
136 if !visited2[start] {
137 let mut scc = Vec::new();
138 dfs_collect(start, &radj, &mut visited2, &mut scc);
139 sccs.push(scc);
140 }
141 }
142 sccs
143}
144
145fn dfs_finish(start: usize, adj: &[Vec<usize>], visited: &mut [bool], order: &mut Vec<usize>) {
146 let mut stack: Vec<(usize, usize)> = vec![(start, 0)];
148 visited[start] = true;
149 while let Some((u, ni)) = stack.last_mut() {
150 let u = *u;
151 if *ni < adj[u].len() {
152 let v = adj[u][*ni];
153 *ni += 1;
154 if !visited[v] {
155 visited[v] = true;
156 stack.push((v, 0));
157 }
158 } else {
159 stack.pop();
160 order.push(u);
161 }
162 }
163}
164
165fn dfs_collect(start: usize, adj: &[Vec<usize>], visited: &mut [bool], scc: &mut Vec<usize>) {
166 let mut stack = vec![start];
167 visited[start] = true;
168 while let Some(u) = stack.pop() {
169 scc.push(u);
170 for &v in &adj[u] {
171 if !visited[v] {
172 visited[v] = true;
173 stack.push(v);
174 }
175 }
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::graph::{Edge, EdgeKind, Node, NodeKind};
183
184 fn node(id: &str, name: &str, kind: NodeKind) -> Node {
185 Node {
186 id: id.into(),
187 kind,
188 name: name.into(),
189 path: String::new(),
190 parent: None,
191 external: None,
192 version: None,
193 visibility: None,
194 loc: None,
195 line: None,
196 item_count: None,
197 method_count: None,
198 complexity: None,
199 cycle_kind: None,
200 }
201 }
202
203 fn mod_node(id: &str) -> Node {
206 node(id, id, NodeKind::Module)
207 }
208
209 fn edge(from: &str, to: &str, kind: EdgeKind) -> Edge {
210 Edge {
211 from: from.into(),
212 to: to.into(),
213 kind,
214 unresolved: None,
215 external: None,
216 visibility: None,
217 }
218 }
219
220 fn graph_of(nodes: Vec<Node>, edges: Vec<Edge>) -> Graph {
221 Graph {
222 nodes,
223 edges,
224 cycles: Vec::new(),
225 stats: None,
226 }
227 }
228
229 fn kind_of(g: &Graph, id: &str) -> Option<CycleKind> {
230 g.nodes.iter().find(|n| n.id == id).unwrap().cycle_kind
231 }
232
233 #[test]
234 fn dag_has_no_cycles() {
235 let mut g = graph_of(
237 vec![mod_node("a"), mod_node("b"), mod_node("c")],
238 vec![
239 edge("a", "b", EdgeKind::Uses),
240 edge("b", "c", EdgeKind::Uses),
241 ],
242 );
243 annotate_graph_cycles(&mut g);
244 assert!(g.cycles.is_empty(), "a DAG has no cycle groups");
245 assert!(
246 g.nodes.iter().all(|n| n.cycle_kind.is_none()),
247 "no node in a DAG is annotated"
248 );
249 }
250
251 #[test]
252 fn two_node_cycle_is_mutual() {
253 let mut g = graph_of(
255 vec![mod_node("a"), mod_node("b")],
256 vec![
257 edge("a", "b", EdgeKind::Uses),
258 edge("b", "a", EdgeKind::Uses),
259 ],
260 );
261 annotate_graph_cycles(&mut g);
262 assert_eq!(g.cycles.len(), 1, "one cycle group");
263 assert_eq!(g.cycles[0].kind, CycleKind::Mutual);
264 assert_eq!(g.cycles[0].nodes.len(), 2);
265 assert_eq!(kind_of(&g, "a"), Some(CycleKind::Mutual));
266 assert_eq!(kind_of(&g, "b"), Some(CycleKind::Mutual));
267 }
268
269 #[test]
270 fn three_node_cycle_is_chain() {
271 let mut g = graph_of(
273 vec![mod_node("a"), mod_node("b"), mod_node("c")],
274 vec![
275 edge("a", "b", EdgeKind::Uses),
276 edge("b", "c", EdgeKind::Uses),
277 edge("c", "a", EdgeKind::Uses),
278 ],
279 );
280 annotate_graph_cycles(&mut g);
281 assert_eq!(g.cycles.len(), 1);
282 assert_eq!(g.cycles[0].kind, CycleKind::Chain);
283 assert_eq!(g.cycles[0].nodes.len(), 3);
284 for id in ["a", "b", "c"] {
285 assert_eq!(kind_of(&g, id), Some(CycleKind::Chain), "node {id}");
286 }
287 }
288
289 #[test]
290 fn contains_edge_does_not_form_a_cycle() {
291 let mut g = graph_of(
296 vec![
297 node("m", "m", NodeKind::Module),
298 node("m::child", "child", NodeKind::Module),
299 ],
300 vec![
301 edge("m", "m::child", EdgeKind::Contains),
302 edge("m::child", "m", EdgeKind::Uses),
303 ],
304 );
305 annotate_graph_cycles(&mut g);
306 assert!(
307 g.cycles.is_empty(),
308 "a contains+use parent/child pair is not a cycle"
309 );
310 }
311
312 #[test]
313 fn test_node_detected_by_name_suffix_overrides_chain() {
314 let mut g = graph_of(
317 vec![
318 node("a", "a", NodeKind::Module),
319 node("b", "b", NodeKind::Module),
320 node("c", "foo_tests", NodeKind::Module),
321 ],
322 vec![
323 edge("a", "b", EdgeKind::Uses),
324 edge("b", "c", EdgeKind::Uses),
325 edge("c", "a", EdgeKind::Uses),
326 ],
327 );
328 annotate_graph_cycles(&mut g);
329 assert_eq!(g.cycles[0].kind, CycleKind::TestEmbed);
330 }
331
332 #[test]
333 fn self_loop_is_not_a_cycle() {
334 let mut g = graph_of(vec![mod_node("a")], vec![edge("a", "a", EdgeKind::Uses)]);
336 annotate_graph_cycles(&mut g);
337 assert!(g.cycles.is_empty(), "a self-loop is not a structural cycle");
338 assert_eq!(kind_of(&g, "a"), None);
339 }
340
341 #[test]
342 fn node_outside_the_cycle_stays_unannotated() {
343 let mut g = graph_of(
345 vec![mod_node("a"), mod_node("b"), mod_node("d")],
346 vec![
347 edge("a", "b", EdgeKind::Uses),
348 edge("b", "a", EdgeKind::Uses),
349 edge("a", "d", EdgeKind::Uses),
350 ],
351 );
352 annotate_graph_cycles(&mut g);
353 assert_eq!(g.cycles.len(), 1, "only the a⇄b SCC is a cycle");
354 assert_eq!(kind_of(&g, "a"), Some(CycleKind::Mutual));
355 assert_eq!(kind_of(&g, "b"), Some(CycleKind::Mutual));
356 assert_eq!(
357 kind_of(&g, "d"),
358 None,
359 "the dangling node is not part of a cycle"
360 );
361 }
362
363 #[test]
364 fn disjoint_cycles_get_independent_groups() {
365 let mut g = graph_of(
367 vec![
368 mod_node("a"),
369 mod_node("b"),
370 mod_node("c"),
371 mod_node("d"),
372 mod_node("e"),
373 ],
374 vec![
375 edge("a", "b", EdgeKind::Uses),
376 edge("b", "a", EdgeKind::Uses),
377 edge("c", "d", EdgeKind::Uses),
378 edge("d", "e", EdgeKind::Uses),
379 edge("e", "c", EdgeKind::Uses),
380 ],
381 );
382 annotate_graph_cycles(&mut g);
383 assert_eq!(g.cycles.len(), 2, "two independent cycle groups");
384 let kinds: Vec<CycleKind> = g.cycles.iter().map(|c| c.kind).collect();
385 assert!(kinds.contains(&CycleKind::Mutual), "got {kinds:?}");
386 assert!(kinds.contains(&CycleKind::Chain), "got {kinds:?}");
387 assert_eq!(kind_of(&g, "a"), Some(CycleKind::Mutual));
388 assert_eq!(kind_of(&g, "d"), Some(CycleKind::Chain));
389 }
390
391 #[test]
392 fn empty_graph_is_a_noop() {
393 let mut g = Graph::new();
394 annotate_graph_cycles(&mut g);
395 assert!(g.cycles.is_empty());
396 }
397
398 #[test]
399 fn annotate_all_cycles_annotates_the_file_graph() {
400 let mut graphs = PluginGraphs {
402 files: graph_of(
403 vec![mod_node("a"), mod_node("b")],
404 vec![
405 edge("a", "b", EdgeKind::Uses),
406 edge("b", "a", EdgeKind::Uses),
407 ],
408 ),
409 };
410 annotate_all_cycles(&mut graphs);
411 assert_eq!(graphs.files.cycles.len(), 1);
412 assert_eq!(graphs.files.cycles[0].kind, CycleKind::Mutual);
413 }
414}