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