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