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