1mod propagate;
4#[cfg(test)]
5mod tests;
6
7use std::collections::VecDeque;
8use std::path::PathBuf;
9
10use rustc_hash::{FxHashMap, FxHashSet};
11
12#[cfg(test)]
13use std::cell::{Cell, RefCell};
14
15use crate::resolve::ResolvedModule;
16use fallow_types::discover::FileId;
17
18use super::types::{ReferencePathInterner, RoutedReferenceKey};
19use super::{Edge, ModuleGraph};
20
21use propagate::{
22 EffectiveDeclarationRouteCache, ImportBindingUsageIndex, NamedPropagationScratch,
23 NamedReExportPropagation, StarReExportPropagation, propagate_named_re_export,
24 propagate_star_re_export,
25};
26
27#[cfg(test)]
28thread_local! {
29 static PROPAGATION_VISITS: RefCell<Option<Vec<(FileId, FileId)>>> =
30 const { RefCell::new(None) };
31 static DIFFERENTIAL_CHECK_ENABLED: Cell<bool> = const { Cell::new(false) };
32}
33
34#[cfg(test)]
35fn record_propagation_visit(entry: &ReExportTuple) {
36 PROPAGATION_VISITS.with(|visits| {
37 if let Some(visits) = visits.borrow_mut().as_mut() {
38 visits.push((entry.barrel, entry.source));
39 }
40 });
41}
42
43#[cfg(test)]
44fn capture_propagation_visits<T>(run: impl FnOnce() -> T) -> (T, Vec<(FileId, FileId)>) {
45 PROPAGATION_VISITS.with(|visits| *visits.borrow_mut() = Some(Vec::new()));
46 let result = run();
47 let visits = PROPAGATION_VISITS.with(|visits| visits.borrow_mut().take().unwrap_or_default());
48 (result, visits)
49}
50
51#[cfg(test)]
52fn with_re_export_differential_check<T>(run: impl FnOnce() -> T) -> T {
53 DIFFERENTIAL_CHECK_ENABLED.with(|enabled| {
54 let previous = enabled.replace(true);
55 let result = run();
56 enabled.set(previous);
57 result
58 })
59}
60
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
69pub struct GraphReExportCycle {
70 pub files: Vec<PathBuf>,
74 pub file_ids: Vec<FileId>,
79 pub is_self_loop: bool,
82}
83
84struct ReExportTuple {
90 barrel: FileId,
91 source: FileId,
92 imported_name: String,
93 exported_name: String,
94 is_type_only: bool,
99}
100
101struct ReExportContext<'a> {
102 entry_star_targets: &'a FxHashSet<FileId>,
103 edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
104 binding_usage: &'a ImportBindingUsageIndex,
105 effective_exports: &'a super::effective_exports::EffectiveExportIndex,
106 existing_refs: &'a mut FxHashSet<RoutedReferenceKey>,
107 synthetic_stubs: &'a mut FxHashSet<(FileId, String, bool)>,
108 declaration_routes: &'a mut EffectiveDeclarationRouteCache,
109 scratch: &'a mut NamedPropagationScratch,
110 reference_paths: &'a mut ReferencePathInterner,
111}
112
113struct ReExportFixpointInput<'a> {
114 re_export_info: &'a [ReExportTuple],
115 entry_star_targets: &'a FxHashSet<FileId>,
116 edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
117 module_by_id: &'a FxHashMap<FileId, &'a ResolvedModule>,
118 reference_paths: &'a mut ReferencePathInterner,
119}
120
121#[cfg(test)]
122struct LegacyReExportFullScan<'a> {
123 modules: &'a mut [super::types::ModuleNode],
124 edges: &'a [Edge],
125 re_export_info: &'a [ReExportTuple],
126 entry_star_targets: &'a FxHashSet<FileId>,
127 edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
128 module_by_id: &'a FxHashMap<FileId, &'a ResolvedModule>,
129 effective_exports: &'a super::effective_exports::EffectiveExportIndex,
130 reference_paths: &'a mut ReferencePathInterner,
131}
132
133struct ReExportPropagationPlan {
140 observers_by_module: FxHashMap<FileId, Vec<usize>>,
141 queue: VecDeque<usize>,
142 enqueued: Vec<bool>,
143}
144
145impl ReExportPropagationPlan {
146 fn new(re_export_info: &[ReExportTuple]) -> Self {
147 let mut observers_by_module: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
148 for (idx, entry) in re_export_info.iter().enumerate() {
149 observers_by_module
150 .entry(entry.barrel)
151 .or_default()
152 .push(idx);
153 }
154
155 Self {
156 observers_by_module,
157 queue: (0..re_export_info.len()).collect(),
158 enqueued: vec![true; re_export_info.len()],
159 }
160 }
161
162 fn pop_front(&mut self) -> Option<usize> {
163 let idx = self.queue.pop_front()?;
164 self.enqueued[idx] = false;
165 Some(idx)
166 }
167
168 fn enqueue_observers(&mut self, changed_module: FileId) {
169 let Some(observers) = self.observers_by_module.get(&changed_module) else {
170 return;
171 };
172 for &idx in observers {
173 if !self.enqueued[idx] {
174 self.enqueued[idx] = true;
175 self.queue.push_back(idx);
176 }
177 }
178 }
179}
180
181impl ModuleGraph {
182 pub(super) fn resolve_re_export_chains(
191 &mut self,
192 module_by_id: &FxHashMap<FileId, &ResolvedModule>,
193 reference_paths: &mut ReferencePathInterner,
194 ) -> Vec<GraphReExportCycle> {
195 let re_export_info = self.collect_re_export_tuples();
196
197 if re_export_info.is_empty() {
198 return Vec::new();
199 }
200
201 let cycles = find_re_export_cycles(&self.modules, &re_export_info);
202
203 let entry_star_targets = self.collect_entry_star_targets();
204 let edges_by_target = self.build_edges_by_target();
205
206 self.run_re_export_fixpoint(ReExportFixpointInput {
207 re_export_info: &re_export_info,
208 entry_star_targets: &entry_star_targets,
209 edges_by_target: &edges_by_target,
210 module_by_id,
211 reference_paths,
212 });
213
214 cycles
215 }
216
217 fn collect_re_export_tuples(&self) -> Vec<ReExportTuple> {
219 self.modules
220 .iter()
221 .flat_map(|m| {
222 m.re_exports.iter().map(move |re| ReExportTuple {
223 barrel: m.file_id,
224 source: re.source_file,
225 imported_name: re.imported_name.clone(),
226 exported_name: re.exported_name.clone(),
227 is_type_only: re.is_type_only,
228 })
229 })
230 .collect()
231 }
232
233 fn collect_entry_star_targets(&self) -> FxHashSet<FileId> {
236 let mut entry_star_targets: FxHashSet<FileId> = self
237 .modules
238 .iter()
239 .filter(|m| m.is_entry_point())
240 .flat_map(|m| {
241 m.re_exports
242 .iter()
243 .filter(|re| re.exported_name == "*")
244 .map(|re| re.source_file)
245 })
246 .collect();
247 let mut entry_star_stack: Vec<FileId> = entry_star_targets.iter().copied().collect();
248 while let Some(file_id) = entry_star_stack.pop() {
249 let idx = file_id.0 as usize;
250 if idx >= self.modules.len() {
251 continue;
252 }
253
254 for re in self.modules[idx]
255 .re_exports
256 .iter()
257 .filter(|re| re.exported_name == "*")
258 {
259 if entry_star_targets.insert(re.source_file) {
260 entry_star_stack.push(re.source_file);
261 }
262 }
263 }
264 entry_star_targets
265 }
266
267 fn build_edges_by_target(&self) -> FxHashMap<FileId, Vec<usize>> {
269 let mut edges_by_target: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
270 for (idx, edge) in self.edges.iter().enumerate() {
271 edges_by_target.entry(edge.target).or_default().push(idx);
272 }
273 edges_by_target
274 }
275
276 fn run_re_export_fixpoint(&mut self, input: ReExportFixpointInput<'_>) {
278 let ReExportFixpointInput {
279 re_export_info,
280 entry_star_targets,
281 edges_by_target,
282 module_by_id,
283 reference_paths,
284 } = input;
285 #[cfg(test)]
286 let mut legacy_modules: Option<Vec<super::types::ModuleNode>> = DIFFERENTIAL_CHECK_ENABLED
287 .with(|enabled| {
288 enabled.get().then(|| {
289 serde_json::from_value(
290 serde_json::to_value(&self.modules)
291 .expect("module graph should serialize for differential testing"),
292 )
293 .expect("module graph should deserialize for differential testing")
294 })
295 });
296
297 let safety_cap = self.re_export_transition_safety_cap(re_export_info);
298 let mut processed = 0usize;
299 let mut plan = ReExportPropagationPlan::new(re_export_info);
300 let mut existing_refs: FxHashSet<RoutedReferenceKey> = FxHashSet::default();
301 let mut synthetic_stubs: FxHashSet<(FileId, String, bool)> = FxHashSet::default();
302 let binding_usage = ImportBindingUsageIndex::build(module_by_id);
303 let mut declaration_routes = EffectiveDeclarationRouteCache::default();
304 let mut scratch = NamedPropagationScratch::default();
305
306 while let Some(entry_idx) = plan.pop_front() {
307 if processed >= safety_cap {
308 tracing::error!(
309 processed,
310 safety_cap,
311 re_export_edges = re_export_info.len(),
312 "Re-export propagation exceeded its finite-state safety cap; \
313 propagation may be non-monotonic. Please file a bug at \
314 https://github.com/fallow-rs/fallow/issues with the repro."
315 );
316 break;
317 }
318 processed += 1;
319
320 let mut context = ReExportContext {
321 entry_star_targets,
322 edges_by_target,
323 binding_usage: &binding_usage,
324 effective_exports: &self.effective_exports,
325 existing_refs: &mut existing_refs,
326 synthetic_stubs: &mut synthetic_stubs,
327 declaration_routes: &mut declaration_routes,
328 scratch: &mut scratch,
329 reference_paths,
330 };
331
332 let entry = &re_export_info[entry_idx];
333 #[cfg(test)]
334 record_propagation_visit(entry);
335 if Self::propagate_re_export_entry(&mut self.modules, &self.edges, entry, &mut context)
336 {
337 plan.enqueue_observers(entry.source);
338 }
339 }
340
341 #[cfg(test)]
342 if let Some(legacy_modules) = legacy_modules.as_mut() {
343 Self::run_re_export_full_scan(LegacyReExportFullScan {
344 modules: legacy_modules,
345 edges: &self.edges,
346 re_export_info,
347 entry_star_targets,
348 edges_by_target,
349 module_by_id,
350 effective_exports: &self.effective_exports,
351 reference_paths,
352 });
353 assert_eq!(
354 serde_json::to_value(legacy_modules)
355 .expect("legacy module graph should serialize for comparison"),
356 serde_json::to_value(&self.modules)
357 .expect("queue module graph should serialize for comparison"),
358 "work-queue propagation must match the legacy full-scan fixpoint"
359 );
360 }
361 }
362
363 fn re_export_transition_safety_cap(&self, re_export_info: &[ReExportTuple]) -> usize {
366 let initial_exports = self
367 .modules
368 .iter()
369 .map(|module| module.exports.len())
370 .sum::<usize>();
371 let named_inputs = self
372 .edges
373 .iter()
374 .flat_map(|edge| &edge.symbols)
375 .filter(|symbol| {
376 matches!(
377 &symbol.imported_name,
378 fallow_types::extract::ImportedName::Named(_)
379 )
380 })
381 .count()
382 .saturating_add(initial_exports)
383 .saturating_add(re_export_info.len());
384
385 let module_count = self.modules.len();
386 let synthetic_export_hosts = self
387 .modules
388 .iter()
389 .filter(|module| {
390 module
391 .re_exports
392 .iter()
393 .any(|re_export| re_export.exported_name == "*")
394 })
395 .count();
396 let synthetic_exports = synthetic_export_hosts
397 .saturating_mul(named_inputs)
398 .saturating_mul(2);
399 let max_exports = initial_exports.saturating_add(synthetic_exports);
400 let reference_additions = max_exports.saturating_mul(module_count).saturating_mul(2);
401 let state_changes = synthetic_exports.saturating_add(reference_additions);
402
403 re_export_info
404 .len()
405 .saturating_add(state_changes.saturating_mul(re_export_info.len()))
406 .max(re_export_info.len())
407 }
408
409 fn propagate_re_export_entry(
411 modules: &mut [super::types::ModuleNode],
412 edges: &[Edge],
413 entry: &ReExportTuple,
414 context: &mut ReExportContext<'_>,
415 ) -> bool {
416 let barrel_idx = entry.barrel.0 as usize;
417 let source_idx = entry.source.0 as usize;
418
419 if barrel_idx >= modules.len() || source_idx >= modules.len() {
420 return false;
421 }
422
423 if entry.exported_name == "*" {
424 propagate_star_re_export(StarReExportPropagation {
425 modules,
426 edges,
427 edges_by_target: context.edges_by_target,
428 binding_usage: context.binding_usage,
429 effective_exports: context.effective_exports,
430 barrel_id: entry.barrel,
431 barrel_idx,
432 source_id: entry.source,
433 source_idx,
434 entry_star_targets: context.entry_star_targets,
435 triggering_is_type_only: entry.is_type_only,
436 synthetic_stubs: context.synthetic_stubs,
437 reference_paths: context.reference_paths,
438 })
439 } else {
440 propagate_named_re_export(NamedReExportPropagation {
441 modules,
442 effective_exports: context.effective_exports,
443 barrel_id: entry.barrel,
444 barrel_idx,
445 source_id: entry.source,
446 source_idx,
447 imported_name: &entry.imported_name,
448 exported_name: &entry.exported_name,
449 is_type_only: entry.is_type_only,
450 existing_refs: context.existing_refs,
451 declaration_routes: context.declaration_routes,
452 scratch: context.scratch,
453 reference_paths: context.reference_paths,
454 })
455 }
456 }
457
458 #[cfg(test)]
459 fn run_re_export_full_scan(input: LegacyReExportFullScan<'_>) {
460 let LegacyReExportFullScan {
461 modules,
462 edges,
463 re_export_info,
464 entry_star_targets,
465 edges_by_target,
466 module_by_id,
467 effective_exports,
468 reference_paths,
469 } = input;
470 let max_iterations = re_export_info.len().saturating_add(1);
471 let mut existing_refs: FxHashSet<RoutedReferenceKey> = FxHashSet::default();
472 let mut synthetic_stubs: FxHashSet<(FileId, String, bool)> = FxHashSet::default();
473 let binding_usage = ImportBindingUsageIndex::build(module_by_id);
474 let mut declaration_routes = EffectiveDeclarationRouteCache::default();
475 let mut scratch = NamedPropagationScratch::default();
476
477 for _ in 0..max_iterations {
478 let mut changed = false;
479 for entry in re_export_info {
480 let mut context = ReExportContext {
481 entry_star_targets,
482 edges_by_target,
483 binding_usage: &binding_usage,
484 effective_exports,
485 existing_refs: &mut existing_refs,
486 synthetic_stubs: &mut synthetic_stubs,
487 declaration_routes: &mut declaration_routes,
488 scratch: &mut scratch,
489 reference_paths,
490 };
491 changed |= Self::propagate_re_export_entry(modules, edges, entry, &mut context);
492 }
493 if !changed {
494 break;
495 }
496 }
497 }
498}
499
500fn find_re_export_cycles(
509 modules: &[super::types::ModuleNode],
510 re_export_info: &[ReExportTuple],
511) -> Vec<GraphReExportCycle> {
512 let mut cycles: Vec<GraphReExportCycle> = Vec::new();
513
514 let (node_index, nodes) = build_re_export_node_index(re_export_info);
515 let n = nodes.len();
516 if n == 0 {
517 return cycles;
518 }
519
520 let adj = build_re_export_adjacency(re_export_info, &node_index, modules, &mut cycles);
521
522 let sccs = tarjan_scc(n, &adj);
523
524 for scc in &sccs {
525 if scc.len() < 2 {
526 continue;
527 }
528 cycles.push(build_multi_node_cycle(scc, &nodes, modules));
529 }
530
531 cycles
532}
533
534fn build_re_export_node_index(
536 re_export_info: &[ReExportTuple],
537) -> (FxHashMap<FileId, usize>, Vec<FileId>) {
538 let mut node_index: FxHashMap<FileId, usize> = FxHashMap::default();
539 let mut nodes: Vec<FileId> = Vec::new();
540 for entry in re_export_info {
541 for &id in &[entry.barrel, entry.source] {
542 node_index.entry(id).or_insert_with(|| {
543 let idx = nodes.len();
544 nodes.push(id);
545 idx
546 });
547 }
548 }
549 (node_index, nodes)
550}
551
552fn build_re_export_adjacency(
555 re_export_info: &[ReExportTuple],
556 node_index: &FxHashMap<FileId, usize>,
557 modules: &[super::types::ModuleNode],
558 cycles: &mut Vec<GraphReExportCycle>,
559) -> Vec<Vec<usize>> {
560 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); node_index.len()];
561 let mut seen_edge: FxHashSet<(usize, usize)> = FxHashSet::default();
562 let mut seen_self_loop: FxHashSet<FileId> = FxHashSet::default();
563 for entry in re_export_info {
564 let from = node_index[&entry.barrel];
565 let to = node_index[&entry.source];
566 if from == to {
567 if seen_self_loop.insert(entry.barrel) {
568 cycles.push(build_self_loop_cycle(entry.barrel, modules));
569 }
570 continue;
571 }
572 if seen_edge.insert((from, to)) {
573 adj[from].push(to);
574 }
575 }
576 adj
577}
578
579fn build_self_loop_cycle(
581 barrel: FileId,
582 modules: &[super::types::ModuleNode],
583) -> GraphReExportCycle {
584 let (path_buf, path_display) = module_path_and_display(barrel, modules);
585 tracing::warn!(
586 file = path_display.as_str(),
587 "Re-export self-loop detected: this file re-exports from \
588 itself. Chain propagation is structurally a no-op for \
589 these edges. Inspect the barrel for an accidental \
590 `export * from './<this-file>'` after a rename or move."
591 );
592 GraphReExportCycle {
593 files: vec![path_buf],
594 file_ids: vec![barrel],
595 is_self_loop: true,
596 }
597}
598
599fn build_multi_node_cycle(
601 scc: &[usize],
602 nodes: &[FileId],
603 modules: &[super::types::ModuleNode],
604) -> GraphReExportCycle {
605 let mut triples: Vec<(PathBuf, String, FileId)> = scc
606 .iter()
607 .map(|&idx| {
608 let file_id = nodes[idx];
609 let (path, display) = module_path_and_display(file_id, modules);
610 (path, display, file_id)
611 })
612 .collect();
613 triples.sort_by(|a, b| a.1.cmp(&b.1));
614 let members = triples
615 .iter()
616 .map(|(_, d, _)| d.as_str())
617 .collect::<Vec<_>>()
618 .join(" <-> ");
619 tracing::warn!(
620 cycle_size = scc.len(),
621 members = members.as_str(),
622 "Re-export cycle detected: chain propagation may be incomplete \
623 for symbols on this barrel loop. Break the cycle to restore \
624 full reachability analysis."
625 );
626 let (files, file_ids) = triples.into_iter().fold(
627 (Vec::new(), Vec::new()),
628 |(mut paths, mut ids), (p, _, id)| {
629 paths.push(p);
630 ids.push(id);
631 (paths, ids)
632 },
633 );
634 GraphReExportCycle {
635 files,
636 file_ids,
637 is_self_loop: false,
638 }
639}
640
641fn module_path_and_display(
644 file_id: FileId,
645 modules: &[super::types::ModuleNode],
646) -> (PathBuf, String) {
647 let i = file_id.0 as usize;
648 if i < modules.len() {
649 let p = modules[i].path.clone();
650 let d = p.display().to_string();
651 (p, d)
652 } else {
653 let placeholder = format!("<file id {i}>");
654 (PathBuf::from(&placeholder), placeholder)
655 }
656}
657
658struct TarjanFrame {
659 node: usize,
660 next_succ: usize,
661}
662
663struct TarjanState {
665 index_counter: u32,
666 indices: Vec<u32>,
667 lowlinks: Vec<u32>,
668 on_stack: fixedbitset::FixedBitSet,
669 stack: Vec<usize>,
670 sccs: Vec<Vec<usize>>,
671}
672
673impl TarjanState {
674 fn new(n: usize) -> Self {
675 Self {
676 index_counter: 0,
677 indices: vec![u32::MAX; n],
678 lowlinks: vec![0; n],
679 on_stack: fixedbitset::FixedBitSet::with_capacity(n),
680 stack: Vec::new(),
681 sccs: Vec::new(),
682 }
683 }
684
685 fn discover(&mut self, node: usize) {
687 self.indices[node] = self.index_counter;
688 self.lowlinks[node] = self.index_counter;
689 self.index_counter = self.index_counter.saturating_add(1);
690 self.stack.push(node);
691 self.on_stack.insert(node);
692 }
693
694 fn step_successor(&mut self, frame: &mut TarjanFrame, adj: &[Vec<usize>]) -> Option<usize> {
697 let v = frame.node;
698 let w = adj[v][frame.next_succ];
699 frame.next_succ = frame.next_succ.saturating_add(1);
700 if self.indices[w] == u32::MAX {
701 self.discover(w);
702 Some(w)
703 } else {
704 if self.on_stack.contains(w) {
705 self.lowlinks[v] = self.lowlinks[v].min(self.indices[w]);
706 }
707 None
708 }
709 }
710
711 fn finish_frame(&mut self, v: usize, parent: Option<usize>) {
714 if self.lowlinks[v] == self.indices[v] {
715 let mut scc = Vec::new();
716 while let Some(w) = self.stack.pop() {
717 self.on_stack.remove(w);
718 scc.push(w);
719 if w == v {
720 break;
721 }
722 }
723 self.sccs.push(scc);
724 }
725 if let Some(pv) = parent {
726 self.lowlinks[pv] = self.lowlinks[pv].min(self.lowlinks[v]);
727 }
728 }
729}
730
731fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
735 let mut state = TarjanState::new(n);
736
737 for start in 0..n {
738 if state.indices[start] != u32::MAX {
739 continue;
740 }
741 state.discover(start);
742 let mut dfs: Vec<TarjanFrame> = vec![TarjanFrame {
743 node: start,
744 next_succ: 0,
745 }];
746
747 while let Some(frame) = dfs.last_mut() {
748 let v = frame.node;
749 if frame.next_succ < adj[v].len() {
750 if let Some(child) = state.step_successor(frame, adj) {
751 dfs.push(TarjanFrame {
752 node: child,
753 next_succ: 0,
754 });
755 }
756 } else {
757 dfs.pop();
758 state.finish_frame(v, dfs.last().map(|parent| parent.node));
759 }
760 }
761 }
762
763 state.sccs
764}