Skip to main content

lean_ctx/core/graph_analysis/
cycles.rs

1//! Import-cycle detection via strongly-connected components.
2//!
3//! A cycle is an SCC of size >= 2 in the directed dependency graph: a group of
4//! files that (transitively) import each other. Tarjan's algorithm is used in an
5//! *iterative* form so deep dependency chains in large repos cannot overflow the
6//! stack.
7
8use std::collections::HashMap;
9
10use serde::Serialize;
11
12use super::dependency_edges;
13use crate::core::graph_provider::EdgeInfo;
14
15/// A circular dependency: a set of files that import each other.
16#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
17pub struct ImportCycle {
18    pub files: Vec<String>,
19    pub size: usize,
20}
21
22/// Finds import cycles (SCCs of size >= 2), largest first, capped at `limit`.
23/// Output is deterministic.
24pub 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    // Intern node names to dense indices and build the adjacency list.
31    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
69/// Iterative Tarjan strongly-connected-components.
70fn 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        // Explicit DFS stack of (node, next-neighbour-index).
86        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                // v fully explored: if it is an SCC root, pop the component.
109                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"), // d is a non-cyclic dependency
152        ];
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"),   // self-loop: not a cycle
180            e("b.rs", "c.rs", "sibling"),  // heuristic, ignored
181            e("c.rs", "b.rs", "cochange"), // heuristic, ignored
182        ];
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            // 2-cycle
193            e("x.rs", "y.rs", "import"),
194            e("y.rs", "x.rs", "import"),
195            // 3-cycle
196            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); // largest first
203        assert_eq!(cycles[1].size, 2);
204    }
205}