lean_ctx/core/graph_analysis/
cycles.rs1use std::collections::HashMap;
9
10use serde::Serialize;
11
12use super::dependency_edges;
13use crate::core::graph_provider::EdgeInfo;
14
15#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
17pub struct ImportCycle {
18 pub files: Vec<String>,
19 pub size: usize,
20}
21
22pub fn find_import_cycles(edges: &[EdgeInfo], limit: usize) -> Vec<ImportCycle> {
25 let deps = dependency_edges(edges);
26 if deps.is_empty() {
27 return Vec::new();
28 }
29
30 let mut idx_of: HashMap<&str, usize> = HashMap::new();
32 let mut names: Vec<&str> = Vec::new();
33 let mut adj: Vec<Vec<usize>> = Vec::new();
34 for (from, to) in &deps {
35 for s in [*from, *to] {
36 if !idx_of.contains_key(s) {
37 idx_of.insert(s, names.len());
38 names.push(s);
39 adj.push(Vec::new());
40 }
41 }
42 }
43 for (from, to) in &deps {
44 let f = idx_of[*from];
45 let t = idx_of[*to];
46 adj[f].push(t);
47 }
48
49 let sccs = tarjan_scc(&adj);
50
51 let mut cycles: Vec<ImportCycle> = sccs
52 .into_iter()
53 .filter(|c| c.len() >= 2)
54 .map(|c| {
55 let mut files: Vec<String> = c.into_iter().map(|i| names[i].to_string()).collect();
56 files.sort();
57 ImportCycle {
58 size: files.len(),
59 files,
60 }
61 })
62 .collect();
63
64 cycles.sort_by(|a, b| b.size.cmp(&a.size).then_with(|| a.files.cmp(&b.files)));
65 cycles.truncate(limit);
66 cycles
67}
68
69fn tarjan_scc(adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
71 let n = adj.len();
72 const UNVISITED: usize = usize::MAX;
73
74 let mut indices = vec![UNVISITED; n];
75 let mut lowlink = vec![0usize; n];
76 let mut on_stack = vec![false; n];
77 let mut tarjan_stack: Vec<usize> = Vec::new();
78 let mut sccs: Vec<Vec<usize>> = Vec::new();
79 let mut counter = 0usize;
80
81 for start in 0..n {
82 if indices[start] != UNVISITED {
83 continue;
84 }
85 let mut call_stack: Vec<(usize, usize)> = vec![(start, 0)];
87 while let Some(&(v, edge_i)) = call_stack.last() {
88 if edge_i == 0 {
89 indices[v] = counter;
90 lowlink[v] = counter;
91 counter += 1;
92 tarjan_stack.push(v);
93 on_stack[v] = true;
94 }
95
96 if edge_i < adj[v].len() {
97 call_stack
98 .last_mut()
99 .expect("call_stack non-empty in Tarjan loop")
100 .1 += 1;
101 let w = adj[v][edge_i];
102 if indices[w] == UNVISITED {
103 call_stack.push((w, 0));
104 } else if on_stack[w] {
105 lowlink[v] = lowlink[v].min(indices[w]);
106 }
107 } else {
108 if lowlink[v] == indices[v] {
110 let mut component = Vec::new();
111 loop {
112 let w = tarjan_stack.pop().expect("tarjan stack non-empty");
113 on_stack[w] = false;
114 component.push(w);
115 if w == v {
116 break;
117 }
118 }
119 sccs.push(component);
120 }
121 call_stack.pop();
122 if let Some(&(parent, _)) = call_stack.last() {
123 lowlink[parent] = lowlink[parent].min(lowlink[v]);
124 }
125 }
126 }
127 }
128
129 sccs
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 fn e(from: &str, to: &str, kind: &str) -> EdgeInfo {
137 EdgeInfo {
138 from: from.into(),
139 to: to.into(),
140 kind: kind.into(),
141 weight: 1.0,
142 }
143 }
144
145 #[test]
146 fn detects_three_node_cycle() {
147 let edges = vec![
148 e("a.rs", "b.rs", "import"),
149 e("b.rs", "c.rs", "import"),
150 e("c.rs", "a.rs", "import"),
151 e("a.rs", "d.rs", "import"), ];
153 let cycles = find_import_cycles(&edges, 10);
154 assert_eq!(cycles.len(), 1);
155 assert_eq!(cycles[0].size, 3);
156 assert_eq!(cycles[0].files, vec!["a.rs", "b.rs", "c.rs"]);
157 }
158
159 #[test]
160 fn detects_two_node_cycle() {
161 let edges = vec![e("a.rs", "b.rs", "import"), e("b.rs", "a.rs", "reexport")];
162 let cycles = find_import_cycles(&edges, 10);
163 assert_eq!(cycles.len(), 1);
164 assert_eq!(cycles[0].files, vec!["a.rs", "b.rs"]);
165 }
166
167 #[test]
168 fn acyclic_graph_has_no_cycles() {
169 let edges = vec![e("a.rs", "b.rs", "import"), e("b.rs", "c.rs", "import")];
170 {
171 let found = &find_import_cycles(&edges, 10);
172 assert!(found.is_empty(), "{found:?}");
173 }
174 }
175
176 #[test]
177 fn self_loops_and_heuristics_excluded() {
178 let edges = vec![
179 e("a.rs", "a.rs", "import"), e("b.rs", "c.rs", "sibling"), e("c.rs", "b.rs", "cochange"), ];
183 {
184 let found = &find_import_cycles(&edges, 10);
185 assert!(found.is_empty(), "{found:?}");
186 }
187 }
188
189 #[test]
190 fn two_separate_cycles_sorted_by_size() {
191 let edges = vec![
192 e("x.rs", "y.rs", "import"),
194 e("y.rs", "x.rs", "import"),
195 e("a.rs", "b.rs", "import"),
197 e("b.rs", "c.rs", "import"),
198 e("c.rs", "a.rs", "import"),
199 ];
200 let cycles = find_import_cycles(&edges, 10);
201 assert_eq!(cycles.len(), 2);
202 assert_eq!(cycles[0].size, 3); assert_eq!(cycles[1].size, 2);
204 }
205}