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