fallow_graph/graph/re_exports/
mod.rs1mod propagate;
4#[cfg(test)]
5mod tests;
6
7use std::path::PathBuf;
8
9use rustc_hash::{FxHashMap, FxHashSet};
10
11use fallow_types::discover::FileId;
12
13use super::ModuleGraph;
14
15use propagate::{propagate_named_re_export, propagate_star_re_export};
16
17#[derive(Debug, Clone)]
25pub struct GraphReExportCycle {
26 pub files: Vec<PathBuf>,
30 pub file_ids: Vec<FileId>,
35 pub is_self_loop: bool,
38}
39
40struct ReExportTuple {
46 barrel: FileId,
47 source: FileId,
48 imported_name: String,
49 exported_name: String,
50 is_type_only: bool,
55}
56
57impl ModuleGraph {
58 pub(super) fn resolve_re_export_chains(&mut self) -> Vec<GraphReExportCycle> {
67 let re_export_info: Vec<ReExportTuple> = self
68 .modules
69 .iter()
70 .flat_map(|m| {
71 m.re_exports.iter().map(move |re| ReExportTuple {
72 barrel: m.file_id,
73 source: re.source_file,
74 imported_name: re.imported_name.clone(),
75 exported_name: re.exported_name.clone(),
76 is_type_only: re.is_type_only,
77 })
78 })
79 .collect();
80
81 if re_export_info.is_empty() {
82 return Vec::new();
83 }
84
85 let cycles = find_re_export_cycles(&self.modules, &re_export_info);
86
87 let mut entry_star_targets: FxHashSet<FileId> = self
88 .modules
89 .iter()
90 .filter(|m| m.is_entry_point())
91 .flat_map(|m| {
92 m.re_exports
93 .iter()
94 .filter(|re| re.exported_name == "*")
95 .map(|re| re.source_file)
96 })
97 .collect();
98 let mut entry_star_stack: Vec<FileId> = entry_star_targets.iter().copied().collect();
99 while let Some(file_id) = entry_star_stack.pop() {
100 let idx = file_id.0 as usize;
101 if idx >= self.modules.len() {
102 continue;
103 }
104
105 for re in self.modules[idx]
106 .re_exports
107 .iter()
108 .filter(|re| re.exported_name == "*")
109 {
110 if entry_star_targets.insert(re.source_file) {
111 entry_star_stack.push(re.source_file);
112 }
113 }
114 }
115
116 let mut edges_by_target: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
117 for (idx, edge) in self.edges.iter().enumerate() {
118 edges_by_target.entry(edge.target).or_default().push(idx);
119 }
120
121 let safety_cap = re_export_info.len().saturating_add(1);
122 let mut changed = true;
123 let mut iteration: usize = 0;
124 let mut existing_refs: FxHashSet<FileId> = FxHashSet::default();
125 let mut synthetic_stubs: FxHashSet<(FileId, String)> = FxHashSet::default();
126
127 while changed && iteration < safety_cap {
128 changed = false;
129 iteration += 1;
130
131 for entry in &re_export_info {
132 let barrel_idx = entry.barrel.0 as usize;
133 let source_idx = entry.source.0 as usize;
134
135 if barrel_idx >= self.modules.len() || source_idx >= self.modules.len() {
136 continue;
137 }
138
139 if entry.exported_name == "*" {
140 changed |= propagate_star_re_export(
141 &mut self.modules,
142 &self.edges,
143 &edges_by_target,
144 entry.barrel,
145 barrel_idx,
146 entry.source,
147 source_idx,
148 &entry_star_targets,
149 entry.is_type_only,
150 &mut synthetic_stubs,
151 );
152 } else {
153 changed |= propagate_named_re_export(
154 &mut self.modules,
155 entry.barrel,
156 barrel_idx,
157 source_idx,
158 &entry.imported_name,
159 &entry.exported_name,
160 &mut existing_refs,
161 );
162 }
163 }
164 }
165
166 if iteration >= safety_cap && changed {
167 tracing::error!(
168 iterations = iteration,
169 safety_cap,
170 re_export_edges = re_export_info.len(),
171 "Re-export chain fixpoint exceeded safety cap; \
172 propagation may be non-monotonic. Please file a bug at \
173 https://github.com/fallow-rs/fallow/issues with the repro."
174 );
175 }
176
177 cycles
178 }
179}
180
181fn find_re_export_cycles(
191 modules: &[super::types::ModuleNode],
192 re_export_info: &[ReExportTuple],
193) -> Vec<GraphReExportCycle> {
194 let mut cycles: Vec<GraphReExportCycle> = Vec::new();
195
196 let mut node_index: FxHashMap<FileId, usize> = FxHashMap::default();
197 let mut nodes: Vec<FileId> = Vec::new();
198 for entry in re_export_info {
199 for &id in &[entry.barrel, entry.source] {
200 node_index.entry(id).or_insert_with(|| {
201 let idx = nodes.len();
202 nodes.push(id);
203 idx
204 });
205 }
206 }
207
208 let n = nodes.len();
209 if n == 0 {
210 return cycles;
211 }
212
213 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
214 let mut seen_edge: FxHashSet<(usize, usize)> = FxHashSet::default();
215 let mut seen_self_loop: FxHashSet<FileId> = FxHashSet::default();
216 for entry in re_export_info {
217 let from = node_index[&entry.barrel];
218 let to = node_index[&entry.source];
219 if from == to {
220 if seen_self_loop.insert(entry.barrel) {
221 let i = entry.barrel.0 as usize;
222 let (path_buf, path_display) = if i < modules.len() {
223 let p = modules[i].path.clone();
224 let d = p.display().to_string();
225 (p, d)
226 } else {
227 (
228 PathBuf::from(format!("<file id {i}>")),
229 format!("<file id {i}>"),
230 )
231 };
232 tracing::warn!(
233 file = path_display.as_str(),
234 "Re-export self-loop detected: this file re-exports from \
235 itself. Chain propagation is structurally a no-op for \
236 these edges. Inspect the barrel for an accidental \
237 `export * from './<this-file>'` after a rename or move."
238 );
239 cycles.push(GraphReExportCycle {
240 files: vec![path_buf],
241 file_ids: vec![entry.barrel],
242 is_self_loop: true,
243 });
244 }
245 continue;
246 }
247 if seen_edge.insert((from, to)) {
248 adj[from].push(to);
249 }
250 }
251
252 let sccs = tarjan_scc(n, &adj);
253
254 for scc in &sccs {
255 if scc.len() < 2 {
256 continue;
257 }
258 let mut triples: Vec<(PathBuf, String, FileId)> = scc
259 .iter()
260 .map(|&idx| {
261 let file_id = nodes[idx];
262 let i = file_id.0 as usize;
263 if i < modules.len() {
264 let p = modules[i].path.clone();
265 let d = p.display().to_string();
266 (p, d, file_id)
267 } else {
268 let placeholder = format!("<file id {i}>");
269 (PathBuf::from(&placeholder), placeholder, file_id)
270 }
271 })
272 .collect();
273 triples.sort_by(|a, b| a.1.cmp(&b.1));
274 let members = triples
275 .iter()
276 .map(|(_, d, _)| d.as_str())
277 .collect::<Vec<_>>()
278 .join(" <-> ");
279 tracing::warn!(
280 cycle_size = scc.len(),
281 members = members.as_str(),
282 "Re-export cycle detected: chain propagation may be incomplete \
283 for symbols on this barrel loop. Break the cycle to restore \
284 full reachability analysis."
285 );
286 let (files, file_ids) = triples.into_iter().fold(
287 (Vec::new(), Vec::new()),
288 |(mut paths, mut ids), (p, _, id)| {
289 paths.push(p);
290 ids.push(id);
291 (paths, ids)
292 },
293 );
294 cycles.push(GraphReExportCycle {
295 files,
296 file_ids,
297 is_self_loop: false,
298 });
299 }
300
301 cycles
302}
303
304fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
308 use fixedbitset::FixedBitSet;
309
310 let mut index_counter: u32 = 0;
311 let mut indices: Vec<u32> = vec![u32::MAX; n];
312 let mut lowlinks: Vec<u32> = vec![0; n];
313 let mut on_stack = FixedBitSet::with_capacity(n);
314 let mut stack: Vec<usize> = Vec::new();
315 let mut sccs: Vec<Vec<usize>> = Vec::new();
316
317 struct Frame {
318 node: usize,
319 next_succ: usize,
320 }
321
322 for start in 0..n {
323 if indices[start] != u32::MAX {
324 continue;
325 }
326 let mut dfs: Vec<Frame> = vec![Frame {
327 node: start,
328 next_succ: 0,
329 }];
330 indices[start] = index_counter;
331 lowlinks[start] = index_counter;
332 index_counter = index_counter.saturating_add(1);
333 stack.push(start);
334 on_stack.insert(start);
335
336 while let Some(frame) = dfs.last_mut() {
337 let v = frame.node;
338 if frame.next_succ < adj[v].len() {
339 let w = adj[v][frame.next_succ];
340 frame.next_succ = frame.next_succ.saturating_add(1);
341 if indices[w] == u32::MAX {
342 indices[w] = index_counter;
343 lowlinks[w] = index_counter;
344 index_counter = index_counter.saturating_add(1);
345 stack.push(w);
346 on_stack.insert(w);
347 dfs.push(Frame {
348 node: w,
349 next_succ: 0,
350 });
351 } else if on_stack.contains(w) {
352 lowlinks[v] = lowlinks[v].min(indices[w]);
353 }
354 } else {
355 if lowlinks[v] == indices[v] {
356 let mut scc = Vec::new();
357 while let Some(w) = stack.pop() {
358 on_stack.remove(w);
359 scc.push(w);
360 if w == v {
361 break;
362 }
363 }
364 sccs.push(scc);
365 }
366 dfs.pop();
367 if let Some(parent) = dfs.last_mut() {
368 let pv = parent.node;
369 lowlinks[pv] = lowlinks[pv].min(lowlinks[v]);
370 }
371 }
372 }
373 }
374
375 sccs
376}