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::{
16 NamedReExportPropagation, StarReExportPropagation, propagate_named_re_export,
17 propagate_star_re_export,
18};
19
20#[derive(Debug, Clone)]
28pub struct GraphReExportCycle {
29 pub files: Vec<PathBuf>,
33 pub file_ids: Vec<FileId>,
38 pub is_self_loop: bool,
41}
42
43struct ReExportTuple {
49 barrel: FileId,
50 source: FileId,
51 imported_name: String,
52 exported_name: String,
53 is_type_only: bool,
58}
59
60impl ModuleGraph {
61 pub(super) fn resolve_re_export_chains(&mut self) -> Vec<GraphReExportCycle> {
70 let re_export_info = self.collect_re_export_tuples();
71
72 if re_export_info.is_empty() {
73 return Vec::new();
74 }
75
76 let cycles = find_re_export_cycles(&self.modules, &re_export_info);
77
78 let entry_star_targets = self.collect_entry_star_targets();
79 let edges_by_target = self.build_edges_by_target();
80
81 self.run_re_export_fixpoint(&re_export_info, &entry_star_targets, &edges_by_target);
82
83 cycles
84 }
85
86 fn collect_re_export_tuples(&self) -> Vec<ReExportTuple> {
88 self.modules
89 .iter()
90 .flat_map(|m| {
91 m.re_exports.iter().map(move |re| ReExportTuple {
92 barrel: m.file_id,
93 source: re.source_file,
94 imported_name: re.imported_name.clone(),
95 exported_name: re.exported_name.clone(),
96 is_type_only: re.is_type_only,
97 })
98 })
99 .collect()
100 }
101
102 fn collect_entry_star_targets(&self) -> FxHashSet<FileId> {
105 let mut entry_star_targets: FxHashSet<FileId> = self
106 .modules
107 .iter()
108 .filter(|m| m.is_entry_point())
109 .flat_map(|m| {
110 m.re_exports
111 .iter()
112 .filter(|re| re.exported_name == "*")
113 .map(|re| re.source_file)
114 })
115 .collect();
116 let mut entry_star_stack: Vec<FileId> = entry_star_targets.iter().copied().collect();
117 while let Some(file_id) = entry_star_stack.pop() {
118 let idx = file_id.0 as usize;
119 if idx >= self.modules.len() {
120 continue;
121 }
122
123 for re in self.modules[idx]
124 .re_exports
125 .iter()
126 .filter(|re| re.exported_name == "*")
127 {
128 if entry_star_targets.insert(re.source_file) {
129 entry_star_stack.push(re.source_file);
130 }
131 }
132 }
133 entry_star_targets
134 }
135
136 fn build_edges_by_target(&self) -> FxHashMap<FileId, Vec<usize>> {
138 let mut edges_by_target: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
139 for (idx, edge) in self.edges.iter().enumerate() {
140 edges_by_target.entry(edge.target).or_default().push(idx);
141 }
142 edges_by_target
143 }
144
145 fn run_re_export_fixpoint(
147 &mut self,
148 re_export_info: &[ReExportTuple],
149 entry_star_targets: &FxHashSet<FileId>,
150 edges_by_target: &FxHashMap<FileId, Vec<usize>>,
151 ) {
152 let safety_cap = re_export_info.len().saturating_add(1);
153 let mut changed = true;
154 let mut iteration: usize = 0;
155 let mut existing_refs: FxHashSet<FileId> = FxHashSet::default();
156 let mut synthetic_stubs: FxHashSet<(FileId, String)> = FxHashSet::default();
157
158 while changed && iteration < safety_cap {
159 changed = false;
160 iteration += 1;
161
162 for entry in re_export_info {
163 changed |= self.propagate_re_export_entry(
164 entry,
165 entry_star_targets,
166 edges_by_target,
167 &mut existing_refs,
168 &mut synthetic_stubs,
169 );
170 }
171 }
172
173 if iteration >= safety_cap && changed {
174 tracing::error!(
175 iterations = iteration,
176 safety_cap,
177 re_export_edges = re_export_info.len(),
178 "Re-export chain fixpoint exceeded safety cap; \
179 propagation may be non-monotonic. Please file a bug at \
180 https://github.com/fallow-rs/fallow/issues with the repro."
181 );
182 }
183 }
184
185 fn propagate_re_export_entry(
187 &mut self,
188 entry: &ReExportTuple,
189 entry_star_targets: &FxHashSet<FileId>,
190 edges_by_target: &FxHashMap<FileId, Vec<usize>>,
191 existing_refs: &mut FxHashSet<FileId>,
192 synthetic_stubs: &mut FxHashSet<(FileId, String)>,
193 ) -> bool {
194 let barrel_idx = entry.barrel.0 as usize;
195 let source_idx = entry.source.0 as usize;
196
197 if barrel_idx >= self.modules.len() || source_idx >= self.modules.len() {
198 return false;
199 }
200
201 if entry.exported_name == "*" {
202 propagate_star_re_export(StarReExportPropagation {
203 modules: &mut self.modules,
204 edges: &self.edges,
205 edges_by_target,
206 barrel_id: entry.barrel,
207 barrel_idx,
208 source_id: entry.source,
209 source_idx,
210 entry_star_targets,
211 triggering_is_type_only: entry.is_type_only,
212 synthetic_stubs,
213 })
214 } else {
215 propagate_named_re_export(NamedReExportPropagation {
216 modules: &mut self.modules,
217 barrel_id: entry.barrel,
218 barrel_idx,
219 source_idx,
220 imported_name: &entry.imported_name,
221 exported_name: &entry.exported_name,
222 existing_refs,
223 })
224 }
225 }
226}
227
228fn find_re_export_cycles(
238 modules: &[super::types::ModuleNode],
239 re_export_info: &[ReExportTuple],
240) -> Vec<GraphReExportCycle> {
241 let mut cycles: Vec<GraphReExportCycle> = Vec::new();
242
243 let (node_index, nodes) = build_re_export_node_index(re_export_info);
244 let n = nodes.len();
245 if n == 0 {
246 return cycles;
247 }
248
249 let adj = build_re_export_adjacency(re_export_info, &node_index, modules, &mut cycles);
250
251 let sccs = tarjan_scc(n, &adj);
252
253 for scc in &sccs {
254 if scc.len() < 2 {
255 continue;
256 }
257 cycles.push(build_multi_node_cycle(scc, &nodes, modules));
258 }
259
260 cycles
261}
262
263fn build_re_export_node_index(
265 re_export_info: &[ReExportTuple],
266) -> (FxHashMap<FileId, usize>, Vec<FileId>) {
267 let mut node_index: FxHashMap<FileId, usize> = FxHashMap::default();
268 let mut nodes: Vec<FileId> = Vec::new();
269 for entry in re_export_info {
270 for &id in &[entry.barrel, entry.source] {
271 node_index.entry(id).or_insert_with(|| {
272 let idx = nodes.len();
273 nodes.push(id);
274 idx
275 });
276 }
277 }
278 (node_index, nodes)
279}
280
281fn build_re_export_adjacency(
284 re_export_info: &[ReExportTuple],
285 node_index: &FxHashMap<FileId, usize>,
286 modules: &[super::types::ModuleNode],
287 cycles: &mut Vec<GraphReExportCycle>,
288) -> Vec<Vec<usize>> {
289 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); node_index.len()];
290 let mut seen_edge: FxHashSet<(usize, usize)> = FxHashSet::default();
291 let mut seen_self_loop: FxHashSet<FileId> = FxHashSet::default();
292 for entry in re_export_info {
293 let from = node_index[&entry.barrel];
294 let to = node_index[&entry.source];
295 if from == to {
296 if seen_self_loop.insert(entry.barrel) {
297 cycles.push(build_self_loop_cycle(entry.barrel, modules));
298 }
299 continue;
300 }
301 if seen_edge.insert((from, to)) {
302 adj[from].push(to);
303 }
304 }
305 adj
306}
307
308fn build_self_loop_cycle(
310 barrel: FileId,
311 modules: &[super::types::ModuleNode],
312) -> GraphReExportCycle {
313 let (path_buf, path_display) = module_path_and_display(barrel, modules);
314 tracing::warn!(
315 file = path_display.as_str(),
316 "Re-export self-loop detected: this file re-exports from \
317 itself. Chain propagation is structurally a no-op for \
318 these edges. Inspect the barrel for an accidental \
319 `export * from './<this-file>'` after a rename or move."
320 );
321 GraphReExportCycle {
322 files: vec![path_buf],
323 file_ids: vec![barrel],
324 is_self_loop: true,
325 }
326}
327
328fn build_multi_node_cycle(
330 scc: &[usize],
331 nodes: &[FileId],
332 modules: &[super::types::ModuleNode],
333) -> GraphReExportCycle {
334 let mut triples: Vec<(PathBuf, String, FileId)> = scc
335 .iter()
336 .map(|&idx| {
337 let file_id = nodes[idx];
338 let (path, display) = module_path_and_display(file_id, modules);
339 (path, display, file_id)
340 })
341 .collect();
342 triples.sort_by(|a, b| a.1.cmp(&b.1));
343 let members = triples
344 .iter()
345 .map(|(_, d, _)| d.as_str())
346 .collect::<Vec<_>>()
347 .join(" <-> ");
348 tracing::warn!(
349 cycle_size = scc.len(),
350 members = members.as_str(),
351 "Re-export cycle detected: chain propagation may be incomplete \
352 for symbols on this barrel loop. Break the cycle to restore \
353 full reachability analysis."
354 );
355 let (files, file_ids) = triples.into_iter().fold(
356 (Vec::new(), Vec::new()),
357 |(mut paths, mut ids), (p, _, id)| {
358 paths.push(p);
359 ids.push(id);
360 (paths, ids)
361 },
362 );
363 GraphReExportCycle {
364 files,
365 file_ids,
366 is_self_loop: false,
367 }
368}
369
370fn module_path_and_display(
373 file_id: FileId,
374 modules: &[super::types::ModuleNode],
375) -> (PathBuf, String) {
376 let i = file_id.0 as usize;
377 if i < modules.len() {
378 let p = modules[i].path.clone();
379 let d = p.display().to_string();
380 (p, d)
381 } else {
382 let placeholder = format!("<file id {i}>");
383 (PathBuf::from(&placeholder), placeholder)
384 }
385}
386
387struct TarjanFrame {
388 node: usize,
389 next_succ: usize,
390}
391
392struct TarjanState {
394 index_counter: u32,
395 indices: Vec<u32>,
396 lowlinks: Vec<u32>,
397 on_stack: fixedbitset::FixedBitSet,
398 stack: Vec<usize>,
399 sccs: Vec<Vec<usize>>,
400}
401
402impl TarjanState {
403 fn new(n: usize) -> Self {
404 Self {
405 index_counter: 0,
406 indices: vec![u32::MAX; n],
407 lowlinks: vec![0; n],
408 on_stack: fixedbitset::FixedBitSet::with_capacity(n),
409 stack: Vec::new(),
410 sccs: Vec::new(),
411 }
412 }
413
414 fn discover(&mut self, node: usize) {
416 self.indices[node] = self.index_counter;
417 self.lowlinks[node] = self.index_counter;
418 self.index_counter = self.index_counter.saturating_add(1);
419 self.stack.push(node);
420 self.on_stack.insert(node);
421 }
422
423 fn step_successor(&mut self, frame: &mut TarjanFrame, adj: &[Vec<usize>]) -> Option<usize> {
426 let v = frame.node;
427 let w = adj[v][frame.next_succ];
428 frame.next_succ = frame.next_succ.saturating_add(1);
429 if self.indices[w] == u32::MAX {
430 self.discover(w);
431 Some(w)
432 } else {
433 if self.on_stack.contains(w) {
434 self.lowlinks[v] = self.lowlinks[v].min(self.indices[w]);
435 }
436 None
437 }
438 }
439
440 fn finish_frame(&mut self, v: usize, parent: Option<usize>) {
443 if self.lowlinks[v] == self.indices[v] {
444 let mut scc = Vec::new();
445 while let Some(w) = self.stack.pop() {
446 self.on_stack.remove(w);
447 scc.push(w);
448 if w == v {
449 break;
450 }
451 }
452 self.sccs.push(scc);
453 }
454 if let Some(pv) = parent {
455 self.lowlinks[pv] = self.lowlinks[pv].min(self.lowlinks[v]);
456 }
457 }
458}
459
460fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
464 let mut state = TarjanState::new(n);
465
466 for start in 0..n {
467 if state.indices[start] != u32::MAX {
468 continue;
469 }
470 state.discover(start);
471 let mut dfs: Vec<TarjanFrame> = vec![TarjanFrame {
472 node: start,
473 next_succ: 0,
474 }];
475
476 while let Some(frame) = dfs.last_mut() {
477 let v = frame.node;
478 if frame.next_succ < adj[v].len() {
479 if let Some(child) = state.step_successor(frame, adj) {
480 dfs.push(TarjanFrame {
481 node: child,
482 next_succ: 0,
483 });
484 }
485 } else {
486 dfs.pop();
487 state.finish_frame(v, dfs.last().map(|parent| parent.node));
488 }
489 }
490 }
491
492 state.sccs
493}