1mod propagate;
4#[cfg(test)]
5mod tests;
6
7use std::collections::VecDeque;
8use std::path::PathBuf;
9
10use fixedbitset::FixedBitSet;
11use rustc_hash::{FxHashMap, FxHashSet};
12
13#[cfg(test)]
14use std::cell::{Cell, RefCell};
15
16use crate::resolve::ResolvedModule;
17use fallow_types::discover::FileId;
18
19use super::types::{ReferencePathInterner, RoutedReferenceKey};
20use super::{Edge, ModuleGraph};
21
22use propagate::{
23 EffectiveDeclarationRouteCache, ImportBindingUsageIndex, NamedPropagationScratch,
24 NamedReExportPropagation, StarReExportPropagation, propagate_named_re_export,
25 propagate_star_re_export,
26};
27
28#[cfg(test)]
29thread_local! {
30 static PROPAGATION_VISITS: RefCell<Option<Vec<(FileId, FileId)>>> =
31 const { RefCell::new(None) };
32 static DIFFERENTIAL_CHECK_ENABLED: Cell<bool> = const { Cell::new(false) };
33}
34
35#[cfg(test)]
36fn record_propagation_visit(entry: &ReExportTuple) {
37 PROPAGATION_VISITS.with(|visits| {
38 if let Some(visits) = visits.borrow_mut().as_mut() {
39 visits.push((entry.barrel, entry.source));
40 }
41 });
42}
43
44#[cfg(test)]
45fn capture_propagation_visits<T>(run: impl FnOnce() -> T) -> (T, Vec<(FileId, FileId)>) {
46 PROPAGATION_VISITS.with(|visits| *visits.borrow_mut() = Some(Vec::new()));
47 let result = run();
48 let visits = PROPAGATION_VISITS.with(|visits| visits.borrow_mut().take().unwrap_or_default());
49 (result, visits)
50}
51
52#[cfg(test)]
53fn with_re_export_differential_check<T>(run: impl FnOnce() -> T) -> T {
54 DIFFERENTIAL_CHECK_ENABLED.with(|enabled| {
55 let previous = enabled.replace(true);
56 let result = run();
57 enabled.set(previous);
58 result
59 })
60}
61
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
70pub struct GraphReExportCycle {
71 pub files: Vec<PathBuf>,
75 pub file_ids: Vec<FileId>,
80 pub is_self_loop: bool,
83}
84
85struct ReExportTuple {
91 barrel: FileId,
92 source: FileId,
93 imported_name: String,
94 exported_name: String,
95 is_type_only: bool,
100}
101
102struct ReExportContext<'a> {
103 entry_star_targets: &'a FxHashSet<FileId>,
104 edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
105 binding_usage: &'a ImportBindingUsageIndex,
106 effective_exports: &'a super::effective_exports::EffectiveExportIndex,
107 existing_refs: &'a mut FxHashSet<RoutedReferenceKey>,
108 synthetic_stubs: &'a mut FxHashSet<(FileId, String, bool)>,
109 declaration_routes: &'a mut EffectiveDeclarationRouteCache,
110 scratch: &'a mut NamedPropagationScratch,
111 reference_paths: &'a mut ReferencePathInterner,
112}
113
114#[derive(Clone, Copy, PartialEq, Eq, Debug)]
119enum Exposure {
120 StarSurface,
122 NamespaceObject,
125}
126
127#[derive(Default)]
135pub(in crate::graph) struct WholeModuleObservations {
136 ambient: FxHashMap<FileId, Exposure>,
138 observed: FxHashSet<FileId>,
140}
141
142impl WholeModuleObservations {
143 pub(in crate::graph) fn observe(&mut self, target: FileId) {
145 self.observed.insert(target);
146 }
147
148 pub(in crate::graph) fn observe_ambient_star(&mut self, target: FileId) {
150 self.ambient.entry(target).or_insert(Exposure::StarSurface);
151 }
152
153 pub(in crate::graph) fn observe_ambient_namespace(&mut self, target: FileId) {
155 self.ambient.insert(target, Exposure::NamespaceObject);
156 }
157
158 fn seeds<'a>(
161 &'a self,
162 entry_reachable: &'a FixedBitSet,
163 ) -> impl Iterator<Item = (FileId, Exposure)> + 'a {
164 self.ambient
165 .iter()
166 .map(|(&target, &exposure)| (target, exposure))
167 .chain(
168 self.observed
169 .iter()
170 .copied()
171 .filter(|target| entry_reachable.contains(target.0 as usize))
172 .map(|target| (target, Exposure::NamespaceObject)),
173 )
174 }
175}
176
177pub(in crate::graph) struct ExposedNamespaceTargets {
184 members: FxHashMap<FileId, Exposure>,
185}
186
187impl ExposedNamespaceTargets {
188 pub(in crate::graph) fn is_empty(&self) -> bool {
190 self.members.is_empty()
191 }
192
193 pub(in crate::graph) fn exposes_name(&self, file_id: FileId, exported_name: &str) -> bool {
200 match self.members.get(&file_id) {
201 Some(Exposure::NamespaceObject) => true,
202 Some(Exposure::StarSurface) => exported_name != "default",
203 None => false,
204 }
205 }
206
207 fn files(&self) -> impl Iterator<Item = FileId> + '_ {
213 self.members.keys().copied()
214 }
215
216 fn record(&mut self, stack: &mut Vec<(FileId, Exposure)>, file_id: FileId, exposure: Exposure) {
219 match self.members.entry(file_id) {
220 std::collections::hash_map::Entry::Occupied(mut slot) => {
221 if *slot.get() == Exposure::StarSurface && exposure == Exposure::NamespaceObject {
222 slot.insert(exposure);
223 stack.push((file_id, exposure));
224 }
225 }
226 std::collections::hash_map::Entry::Vacant(slot) => {
227 slot.insert(exposure);
228 stack.push((file_id, exposure));
229 }
230 }
231 }
232}
233
234fn is_namespace_re_export(re: &super::types::ReExportEdge) -> bool {
237 re.imported_name == "*" && re.exported_name != "*"
238}
239
240struct NameForwarders<'a> {
246 named: FxHashMap<(FileId, &'a str), Vec<(FileId, &'a str)>>,
249 stars: FxHashMap<FileId, Vec<FileId>>,
251}
252
253#[derive(Default)]
261struct NameSearchScratch<'a> {
262 visited: FxHashSet<(FileId, &'a str)>,
263 frontier: Vec<(FileId, &'a str)>,
264 failed: FxHashSet<(FileId, &'a str)>,
265}
266
267impl<'a> NameForwarders<'a> {
268 fn build(modules: &'a [super::types::ModuleNode]) -> Self {
269 let mut named: FxHashMap<(FileId, &'a str), Vec<(FileId, &'a str)>> = FxHashMap::default();
270 let mut stars: FxHashMap<FileId, Vec<FileId>> = FxHashMap::default();
271 for module in modules {
272 for re in &module.re_exports {
273 if re.imported_name == "*" {
274 if re.exported_name == "*" {
275 stars
276 .entry(re.source_file)
277 .or_default()
278 .push(module.file_id);
279 }
280 } else {
281 named
282 .entry((re.source_file, re.imported_name.as_str()))
283 .or_default()
284 .push((module.file_id, re.exported_name.as_str()));
285 }
286 }
287 }
288 Self { named, stars }
289 }
290}
291
292fn extend_forwarding_sources(
302 modules: &[super::types::ModuleNode],
303 may_reach: &mut FxHashSet<FileId>,
304 seeds: impl IntoIterator<Item = FileId>,
305) {
306 let mut stack: Vec<FileId> = seeds
307 .into_iter()
308 .filter(|seed| may_reach.insert(*seed))
309 .collect();
310 while let Some(barrel) = stack.pop() {
311 let Some(module) = modules.get(barrel.0 as usize) else {
312 continue;
313 };
314 for re in &module.re_exports {
315 if !is_namespace_re_export(re) && may_reach.insert(re.source_file) {
316 stack.push(re.source_file);
317 }
318 }
319 }
320}
321
322struct ExposedNameSearch<'a> {
332 forwarders: NameForwarders<'a>,
333 whole_object_names: FxHashMap<FileId, Vec<String>>,
338 may_reach: FxHashSet<FileId>,
346 scratch: NameSearchScratch<'a>,
347}
348
349impl<'a> ExposedNameSearch<'a> {
350 fn build(
351 modules: &'a [super::types::ModuleNode],
352 module_by_id: &FxHashMap<FileId, &ResolvedModule>,
353 ) -> Self {
354 let mut whole_object_names: FxHashMap<FileId, Vec<String>> = FxHashMap::default();
355 for consumer in module_by_id.values() {
356 if consumer.whole_object_uses.is_empty() {
357 continue;
358 }
359 for import in &consumer.resolved_imports {
360 let Some(target) = import.target.internal_file_id() else {
361 continue;
362 };
363 let imported_name = match &import.info.imported_name {
364 fallow_types::extract::ImportedName::Named(name) => name.as_str(),
365 fallow_types::extract::ImportedName::Default => "default",
366 _ => continue,
367 };
368 let local_name = import.info.local_name.as_str();
369 if local_name.is_empty()
370 || !consumer
371 .whole_object_uses
372 .iter()
373 .any(|used| used == local_name)
374 {
375 continue;
376 }
377 let names = whole_object_names.entry(target).or_default();
378 if !names.iter().any(|name| name == imported_name) {
379 names.push(imported_name.to_string());
380 }
381 }
382 }
383
384 let mut may_reach = FxHashSet::default();
385 extend_forwarding_sources(
386 modules,
387 &mut may_reach,
388 modules
389 .iter()
390 .filter(|m| m.is_entry_point())
391 .map(|m| m.file_id)
392 .chain(whole_object_names.keys().copied()),
393 );
394
395 Self {
396 forwarders: NameForwarders::build(modules),
397 whole_object_names,
398 may_reach,
399 scratch: NameSearchScratch::default(),
400 }
401 }
402
403 fn refresh(
410 &mut self,
411 modules: &'a [super::types::ModuleNode],
412 closure: &ExposedNamespaceTargets,
413 ) {
414 extend_forwarding_sources(
415 modules,
416 &mut self.may_reach,
417 closure.members.keys().copied(),
418 );
419 self.scratch.failed.clear();
420 }
421
422 fn reaches_exposure(
433 &mut self,
434 graph: &ModuleGraph,
435 closure: &ExposedNamespaceTargets,
436 file: FileId,
437 name: &'a str,
438 ) -> bool {
439 if !self.may_reach.contains(&file) || self.scratch.failed.contains(&(file, name)) {
440 return false;
441 }
442 let Self {
443 forwarders,
444 whole_object_names,
445 may_reach,
446 scratch,
447 } = self;
448 scratch.visited.clear();
449 scratch.frontier.clear();
450 scratch.visited.insert((file, name));
451 scratch.frontier.push((file, name));
452 while let Some((current, current_name)) = scratch.frontier.pop() {
453 if exposes_here(graph, closure, whole_object_names, current, current_name) {
454 return true;
455 }
456 if let Some(barrels) = forwarders.named.get(&(current, current_name)) {
457 for &(barrel, exported_name) in barrels {
458 if may_reach.contains(&barrel)
459 && graph.forwards_binding(current, current_name, barrel, exported_name)
460 && !scratch.failed.contains(&(barrel, exported_name))
461 && scratch.visited.insert((barrel, exported_name))
462 {
463 scratch.frontier.push((barrel, exported_name));
464 }
465 }
466 }
467 if current_name == "default" {
468 continue;
469 }
470 if let Some(barrels) = forwarders.stars.get(¤t) {
471 for &barrel in barrels {
472 if may_reach.contains(&barrel)
473 && graph.forwards_binding(current, current_name, barrel, current_name)
474 && !scratch.failed.contains(&(barrel, current_name))
475 && scratch.visited.insert((barrel, current_name))
476 {
477 scratch.frontier.push((barrel, current_name));
478 }
479 }
480 }
481 }
482 scratch.failed.extend(scratch.visited.iter().copied());
483 false
484 }
485}
486
487fn exposes_here(
494 graph: &ModuleGraph,
495 closure: &ExposedNamespaceTargets,
496 whole_object_names: &FxHashMap<FileId, Vec<String>>,
497 file: FileId,
498 name: &str,
499) -> bool {
500 graph.is_entry_point_file(file)
501 || closure.exposes_name(file, name)
502 || whole_object_names
503 .get(&file)
504 .is_some_and(|names| names.iter().any(|candidate| candidate == name))
505}
506
507struct ReExportFixpointInput<'a> {
508 re_export_info: &'a [ReExportTuple],
509 entry_star_targets: &'a FxHashSet<FileId>,
510 edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
511 module_by_id: &'a FxHashMap<FileId, &'a ResolvedModule>,
512 reference_paths: &'a mut ReferencePathInterner,
513}
514
515#[cfg(test)]
516struct LegacyReExportFullScan<'a> {
517 modules: &'a mut [super::types::ModuleNode],
518 edges: &'a [Edge],
519 re_export_info: &'a [ReExportTuple],
520 entry_star_targets: &'a FxHashSet<FileId>,
521 edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
522 module_by_id: &'a FxHashMap<FileId, &'a ResolvedModule>,
523 effective_exports: &'a super::effective_exports::EffectiveExportIndex,
524 reference_paths: &'a mut ReferencePathInterner,
525}
526
527struct ReExportPropagationPlan {
534 observers_by_module: FxHashMap<FileId, Vec<usize>>,
535 queue: VecDeque<usize>,
536 enqueued: Vec<bool>,
537}
538
539impl ReExportPropagationPlan {
540 fn new(re_export_info: &[ReExportTuple]) -> Self {
541 let mut observers_by_module: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
542 for (idx, entry) in re_export_info.iter().enumerate() {
543 observers_by_module
544 .entry(entry.barrel)
545 .or_default()
546 .push(idx);
547 }
548
549 Self {
550 observers_by_module,
551 queue: (0..re_export_info.len()).collect(),
552 enqueued: vec![true; re_export_info.len()],
553 }
554 }
555
556 fn pop_front(&mut self) -> Option<usize> {
557 let idx = self.queue.pop_front()?;
558 self.enqueued[idx] = false;
559 Some(idx)
560 }
561
562 fn enqueue_observers(&mut self, changed_module: FileId) {
563 let Some(observers) = self.observers_by_module.get(&changed_module) else {
564 return;
565 };
566 for &idx in observers {
567 if !self.enqueued[idx] {
568 self.enqueued[idx] = true;
569 self.queue.push_back(idx);
570 }
571 }
572 }
573}
574
575impl ModuleGraph {
576 pub(super) fn resolve_re_export_chains(
585 &mut self,
586 module_by_id: &FxHashMap<FileId, &ResolvedModule>,
587 exposed_namespace_targets: &ExposedNamespaceTargets,
588 reference_paths: &mut ReferencePathInterner,
589 ) -> Vec<GraphReExportCycle> {
590 let re_export_info = self.collect_re_export_tuples();
591
592 if re_export_info.is_empty() {
593 return Vec::new();
594 }
595
596 let cycles = find_re_export_cycles(&self.modules, &re_export_info);
597
598 let entry_star_targets = self.collect_entry_star_targets(exposed_namespace_targets);
599 let edges_by_target = self.build_edges_by_target();
600
601 self.run_re_export_fixpoint(ReExportFixpointInput {
602 re_export_info: &re_export_info,
603 entry_star_targets: &entry_star_targets,
604 edges_by_target: &edges_by_target,
605 module_by_id,
606 reference_paths,
607 });
608
609 cycles
610 }
611
612 fn collect_re_export_tuples(&self) -> Vec<ReExportTuple> {
614 self.modules
615 .iter()
616 .flat_map(|m| {
617 m.re_exports.iter().map(move |re| ReExportTuple {
618 barrel: m.file_id,
619 source: re.source_file,
620 imported_name: re.imported_name.clone(),
621 exported_name: re.exported_name.clone(),
622 is_type_only: re.is_type_only,
623 })
624 })
625 .collect()
626 }
627
628 fn collect_entry_star_targets(
634 &self,
635 exposed_namespace_targets: &ExposedNamespaceTargets,
636 ) -> FxHashSet<FileId> {
637 let mut entry_star_targets: FxHashSet<FileId> = exposed_namespace_targets.files().collect();
638 entry_star_targets.extend(self.modules.iter().filter(|m| m.is_entry_point()).flat_map(
639 |m| {
640 m.re_exports
641 .iter()
642 .filter(|re| re.exported_name == "*")
643 .map(|re| re.source_file)
644 },
645 ));
646 self.extend_plain_star_closure(&mut entry_star_targets);
647 entry_star_targets
648 }
649
650 pub(in crate::graph) fn collect_exposed_namespace_targets(
697 &self,
698 whole_module_targets: &WholeModuleObservations,
699 entry_reachable: &FixedBitSet,
700 module_by_id: &FxHashMap<FileId, &ResolvedModule>,
701 ) -> ExposedNamespaceTargets {
702 let mut closure = ExposedNamespaceTargets {
703 members: FxHashMap::default(),
704 };
705 let mut stack: Vec<(FileId, Exposure)> = Vec::new();
706 for (seed, exposure) in whole_module_targets.seeds(entry_reachable) {
707 closure.record(&mut stack, seed, exposure);
708 }
709
710 let mut pending: Vec<(FileId, &str, FileId)> = self
711 .modules
712 .iter()
713 .flat_map(|m| {
714 m.re_exports
715 .iter()
716 .filter(|re| is_namespace_re_export(re))
717 .map(move |re| (m.file_id, re.exported_name.as_str(), re.source_file))
718 })
719 .filter(|(_, _, source)| entry_reachable.contains(source.0 as usize))
720 .collect();
721 let mut search =
722 (!pending.is_empty()).then(|| ExposedNameSearch::build(&self.modules, module_by_id));
723
724 loop {
725 self.extend_exposure_walk(&mut closure, &mut stack);
726 let Some(search) = search.as_mut() else { break };
727 if pending.is_empty() {
728 break;
729 }
730 search.refresh(&self.modules, &closure);
731 let mut widened = false;
732 let mut still_pending = Vec::with_capacity(pending.len());
733 for (barrel, exported_name, source) in std::mem::take(&mut pending) {
734 if closure.members.get(&source) == Some(&Exposure::NamespaceObject) {
735 continue;
736 }
737 if search.reaches_exposure(self, &closure, barrel, exported_name) {
738 closure.record(&mut stack, source, Exposure::NamespaceObject);
739 widened = true;
740 } else {
741 still_pending.push((barrel, exported_name, source));
742 }
743 }
744 pending = still_pending;
745 if !widened {
746 break;
747 }
748 }
749 closure
750 }
751
752 fn extend_exposure_walk(
760 &self,
761 closure: &mut ExposedNamespaceTargets,
762 stack: &mut Vec<(FileId, Exposure)>,
763 ) {
764 while let Some((file_id, exposure)) = stack.pop() {
765 let Some(module) = self.modules.get(file_id.0 as usize) else {
766 continue;
767 };
768 for re in &module.re_exports {
769 if re.imported_name != "*" {
770 continue;
771 }
772 let next = if re.exported_name == "*" {
773 Exposure::StarSurface
774 } else if exposure == Exposure::NamespaceObject || re.exported_name != "default" {
775 Exposure::NamespaceObject
776 } else {
777 continue;
778 };
779 closure.record(stack, re.source_file, next);
780 }
781 }
782 }
783
784 fn forwards_binding(
794 &self,
795 source: FileId,
796 source_name: &str,
797 barrel: FileId,
798 barrel_name: &str,
799 ) -> bool {
800 for namespace in [super::ExportNamespace::Value, super::ExportNamespace::Type] {
801 if !matches!(
802 self.resolve_export(source, source_name, namespace),
803 super::EffectiveExportResolution::Unique(_)
804 ) {
805 continue;
806 }
807 return super::namespace_indexes::uniquely_forwards_binding(
808 self,
809 source,
810 source_name,
811 barrel,
812 barrel_name,
813 namespace,
814 );
815 }
816 false
817 }
818
819 fn is_entry_point_file(&self, file_id: FileId) -> bool {
821 self.modules
822 .get(file_id.0 as usize)
823 .is_some_and(super::types::ModuleNode::is_entry_point)
824 }
825
826 fn extend_plain_star_closure(&self, targets: &mut FxHashSet<FileId>) {
829 let mut stack: Vec<FileId> = targets.iter().copied().collect();
830 while let Some(file_id) = stack.pop() {
831 let Some(module) = self.modules.get(file_id.0 as usize) else {
832 continue;
833 };
834 for re in module
835 .re_exports
836 .iter()
837 .filter(|re| re.imported_name == "*" && re.exported_name == "*")
838 {
839 if targets.insert(re.source_file) {
840 stack.push(re.source_file);
841 }
842 }
843 }
844 }
845
846 fn build_edges_by_target(&self) -> FxHashMap<FileId, Vec<usize>> {
848 let mut edges_by_target: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
849 for (idx, edge) in self.edges.iter().enumerate() {
850 edges_by_target.entry(edge.target).or_default().push(idx);
851 }
852 edges_by_target
853 }
854
855 fn run_re_export_fixpoint(&mut self, input: ReExportFixpointInput<'_>) {
857 let ReExportFixpointInput {
858 re_export_info,
859 entry_star_targets,
860 edges_by_target,
861 module_by_id,
862 reference_paths,
863 } = input;
864 #[cfg(test)]
865 let mut legacy_modules: Option<Vec<super::types::ModuleNode>> = DIFFERENTIAL_CHECK_ENABLED
866 .with(|enabled| {
867 enabled.get().then(|| {
868 serde_json::from_value(
869 serde_json::to_value(&self.modules)
870 .expect("module graph should serialize for differential testing"),
871 )
872 .expect("module graph should deserialize for differential testing")
873 })
874 });
875
876 let safety_cap = self.re_export_transition_safety_cap(re_export_info);
877 let mut processed = 0usize;
878 let mut plan = ReExportPropagationPlan::new(re_export_info);
879 let mut existing_refs: FxHashSet<RoutedReferenceKey> = FxHashSet::default();
880 let mut synthetic_stubs: FxHashSet<(FileId, String, bool)> = FxHashSet::default();
881 let binding_usage = ImportBindingUsageIndex::build(module_by_id);
882 let mut declaration_routes = EffectiveDeclarationRouteCache::default();
883 let mut scratch = NamedPropagationScratch::default();
884
885 while let Some(entry_idx) = plan.pop_front() {
886 if processed >= safety_cap {
887 tracing::error!(
888 processed,
889 safety_cap,
890 re_export_edges = re_export_info.len(),
891 "Re-export propagation exceeded its finite-state safety cap; \
892 propagation may be non-monotonic. Please file a bug at \
893 https://github.com/fallow-rs/fallow/issues with the repro."
894 );
895 break;
896 }
897 processed += 1;
898
899 let mut context = ReExportContext {
900 entry_star_targets,
901 edges_by_target,
902 binding_usage: &binding_usage,
903 effective_exports: &self.effective_exports,
904 existing_refs: &mut existing_refs,
905 synthetic_stubs: &mut synthetic_stubs,
906 declaration_routes: &mut declaration_routes,
907 scratch: &mut scratch,
908 reference_paths,
909 };
910
911 let entry = &re_export_info[entry_idx];
912 #[cfg(test)]
913 record_propagation_visit(entry);
914 if Self::propagate_re_export_entry(&mut self.modules, &self.edges, entry, &mut context)
915 {
916 plan.enqueue_observers(entry.source);
917 }
918 }
919
920 #[cfg(test)]
921 if let Some(legacy_modules) = legacy_modules.as_mut() {
922 Self::run_re_export_full_scan(LegacyReExportFullScan {
923 modules: legacy_modules,
924 edges: &self.edges,
925 re_export_info,
926 entry_star_targets,
927 edges_by_target,
928 module_by_id,
929 effective_exports: &self.effective_exports,
930 reference_paths,
931 });
932 assert_eq!(
933 serde_json::to_value(legacy_modules)
934 .expect("legacy module graph should serialize for comparison"),
935 serde_json::to_value(&self.modules)
936 .expect("queue module graph should serialize for comparison"),
937 "work-queue propagation must match the legacy full-scan fixpoint"
938 );
939 }
940 }
941
942 fn re_export_transition_safety_cap(&self, re_export_info: &[ReExportTuple]) -> usize {
945 let initial_exports = self
946 .modules
947 .iter()
948 .map(|module| module.exports.len())
949 .sum::<usize>();
950 let named_inputs = self
951 .edges
952 .iter()
953 .flat_map(|edge| &edge.symbols)
954 .filter(|symbol| {
955 matches!(
956 &symbol.imported_name,
957 fallow_types::extract::ImportedName::Named(_)
958 )
959 })
960 .count()
961 .saturating_add(initial_exports)
962 .saturating_add(re_export_info.len());
963
964 let module_count = self.modules.len();
965 let synthetic_export_hosts = self
966 .modules
967 .iter()
968 .filter(|module| {
969 module
970 .re_exports
971 .iter()
972 .any(|re_export| re_export.exported_name == "*")
973 })
974 .count();
975 let synthetic_exports = synthetic_export_hosts
976 .saturating_mul(named_inputs)
977 .saturating_mul(2);
978 let max_exports = initial_exports.saturating_add(synthetic_exports);
979 let reference_additions = max_exports.saturating_mul(module_count).saturating_mul(2);
980 let state_changes = synthetic_exports.saturating_add(reference_additions);
981
982 re_export_info
983 .len()
984 .saturating_add(state_changes.saturating_mul(re_export_info.len()))
985 .max(re_export_info.len())
986 }
987
988 fn propagate_re_export_entry(
990 modules: &mut [super::types::ModuleNode],
991 edges: &[Edge],
992 entry: &ReExportTuple,
993 context: &mut ReExportContext<'_>,
994 ) -> bool {
995 let barrel_idx = entry.barrel.0 as usize;
996 let source_idx = entry.source.0 as usize;
997
998 if barrel_idx >= modules.len() || source_idx >= modules.len() {
999 return false;
1000 }
1001
1002 if entry.exported_name == "*" {
1003 propagate_star_re_export(StarReExportPropagation {
1004 modules,
1005 edges,
1006 edges_by_target: context.edges_by_target,
1007 binding_usage: context.binding_usage,
1008 effective_exports: context.effective_exports,
1009 barrel_id: entry.barrel,
1010 barrel_idx,
1011 source_id: entry.source,
1012 source_idx,
1013 entry_star_targets: context.entry_star_targets,
1014 triggering_is_type_only: entry.is_type_only,
1015 synthetic_stubs: context.synthetic_stubs,
1016 reference_paths: context.reference_paths,
1017 })
1018 } else {
1019 propagate_named_re_export(NamedReExportPropagation {
1020 modules,
1021 effective_exports: context.effective_exports,
1022 barrel_id: entry.barrel,
1023 barrel_idx,
1024 source_id: entry.source,
1025 source_idx,
1026 imported_name: &entry.imported_name,
1027 exported_name: &entry.exported_name,
1028 is_type_only: entry.is_type_only,
1029 existing_refs: context.existing_refs,
1030 declaration_routes: context.declaration_routes,
1031 scratch: context.scratch,
1032 reference_paths: context.reference_paths,
1033 })
1034 }
1035 }
1036
1037 #[cfg(test)]
1038 fn run_re_export_full_scan(input: LegacyReExportFullScan<'_>) {
1039 let LegacyReExportFullScan {
1040 modules,
1041 edges,
1042 re_export_info,
1043 entry_star_targets,
1044 edges_by_target,
1045 module_by_id,
1046 effective_exports,
1047 reference_paths,
1048 } = input;
1049 let max_iterations = re_export_info.len().saturating_add(1);
1050 let mut existing_refs: FxHashSet<RoutedReferenceKey> = FxHashSet::default();
1051 let mut synthetic_stubs: FxHashSet<(FileId, String, bool)> = FxHashSet::default();
1052 let binding_usage = ImportBindingUsageIndex::build(module_by_id);
1053 let mut declaration_routes = EffectiveDeclarationRouteCache::default();
1054 let mut scratch = NamedPropagationScratch::default();
1055
1056 for _ in 0..max_iterations {
1057 let mut changed = false;
1058 for entry in re_export_info {
1059 let mut context = ReExportContext {
1060 entry_star_targets,
1061 edges_by_target,
1062 binding_usage: &binding_usage,
1063 effective_exports,
1064 existing_refs: &mut existing_refs,
1065 synthetic_stubs: &mut synthetic_stubs,
1066 declaration_routes: &mut declaration_routes,
1067 scratch: &mut scratch,
1068 reference_paths,
1069 };
1070 changed |= Self::propagate_re_export_entry(modules, edges, entry, &mut context);
1071 }
1072 if !changed {
1073 break;
1074 }
1075 }
1076 }
1077}
1078
1079fn find_re_export_cycles(
1088 modules: &[super::types::ModuleNode],
1089 re_export_info: &[ReExportTuple],
1090) -> Vec<GraphReExportCycle> {
1091 let mut cycles: Vec<GraphReExportCycle> = Vec::new();
1092
1093 let (node_index, nodes) = build_re_export_node_index(re_export_info);
1094 let n = nodes.len();
1095 if n == 0 {
1096 return cycles;
1097 }
1098
1099 let adj = build_re_export_adjacency(re_export_info, &node_index, modules, &mut cycles);
1100
1101 let sccs = tarjan_scc(n, &adj);
1102
1103 for scc in &sccs {
1104 if scc.len() < 2 {
1105 continue;
1106 }
1107 cycles.push(build_multi_node_cycle(scc, &nodes, modules));
1108 }
1109
1110 cycles
1111}
1112
1113fn build_re_export_node_index(
1115 re_export_info: &[ReExportTuple],
1116) -> (FxHashMap<FileId, usize>, Vec<FileId>) {
1117 let mut node_index: FxHashMap<FileId, usize> = FxHashMap::default();
1118 let mut nodes: Vec<FileId> = Vec::new();
1119 for entry in re_export_info {
1120 for &id in &[entry.barrel, entry.source] {
1121 node_index.entry(id).or_insert_with(|| {
1122 let idx = nodes.len();
1123 nodes.push(id);
1124 idx
1125 });
1126 }
1127 }
1128 (node_index, nodes)
1129}
1130
1131fn build_re_export_adjacency(
1134 re_export_info: &[ReExportTuple],
1135 node_index: &FxHashMap<FileId, usize>,
1136 modules: &[super::types::ModuleNode],
1137 cycles: &mut Vec<GraphReExportCycle>,
1138) -> Vec<Vec<usize>> {
1139 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); node_index.len()];
1140 let mut seen_edge: FxHashSet<(usize, usize)> = FxHashSet::default();
1141 let mut seen_self_loop: FxHashSet<FileId> = FxHashSet::default();
1142 for entry in re_export_info {
1143 let from = node_index[&entry.barrel];
1144 let to = node_index[&entry.source];
1145 if from == to {
1146 if seen_self_loop.insert(entry.barrel) {
1147 cycles.push(build_self_loop_cycle(entry.barrel, modules));
1148 }
1149 continue;
1150 }
1151 if seen_edge.insert((from, to)) {
1152 adj[from].push(to);
1153 }
1154 }
1155 adj
1156}
1157
1158fn build_self_loop_cycle(
1160 barrel: FileId,
1161 modules: &[super::types::ModuleNode],
1162) -> GraphReExportCycle {
1163 let (path_buf, path_display) = module_path_and_display(barrel, modules);
1164 tracing::warn!(
1165 file = path_display.as_str(),
1166 "Re-export self-loop detected: this file re-exports from \
1167 itself. Chain propagation is structurally a no-op for \
1168 these edges. Inspect the barrel for an accidental \
1169 `export * from './<this-file>'` after a rename or move."
1170 );
1171 GraphReExportCycle {
1172 files: vec![path_buf],
1173 file_ids: vec![barrel],
1174 is_self_loop: true,
1175 }
1176}
1177
1178fn build_multi_node_cycle(
1180 scc: &[usize],
1181 nodes: &[FileId],
1182 modules: &[super::types::ModuleNode],
1183) -> GraphReExportCycle {
1184 let mut triples: Vec<(PathBuf, String, FileId)> = scc
1185 .iter()
1186 .map(|&idx| {
1187 let file_id = nodes[idx];
1188 let (path, display) = module_path_and_display(file_id, modules);
1189 (path, display, file_id)
1190 })
1191 .collect();
1192 triples.sort_by(|a, b| a.1.cmp(&b.1));
1193 let members = triples
1194 .iter()
1195 .map(|(_, d, _)| d.as_str())
1196 .collect::<Vec<_>>()
1197 .join(" <-> ");
1198 tracing::warn!(
1199 cycle_size = scc.len(),
1200 members = members.as_str(),
1201 "Re-export cycle detected: chain propagation may be incomplete \
1202 for symbols on this barrel loop. Break the cycle to restore \
1203 full reachability analysis."
1204 );
1205 let (files, file_ids) = triples.into_iter().fold(
1206 (Vec::new(), Vec::new()),
1207 |(mut paths, mut ids), (p, _, id)| {
1208 paths.push(p);
1209 ids.push(id);
1210 (paths, ids)
1211 },
1212 );
1213 GraphReExportCycle {
1214 files,
1215 file_ids,
1216 is_self_loop: false,
1217 }
1218}
1219
1220fn module_path_and_display(
1223 file_id: FileId,
1224 modules: &[super::types::ModuleNode],
1225) -> (PathBuf, String) {
1226 let i = file_id.0 as usize;
1227 if i < modules.len() {
1228 let p = modules[i].path.clone();
1229 let d = p.display().to_string();
1230 (p, d)
1231 } else {
1232 let placeholder = format!("<file id {i}>");
1233 (PathBuf::from(&placeholder), placeholder)
1234 }
1235}
1236
1237struct TarjanFrame {
1238 node: usize,
1239 next_succ: usize,
1240}
1241
1242struct TarjanState {
1244 index_counter: u32,
1245 indices: Vec<u32>,
1246 lowlinks: Vec<u32>,
1247 on_stack: fixedbitset::FixedBitSet,
1248 stack: Vec<usize>,
1249 sccs: Vec<Vec<usize>>,
1250}
1251
1252impl TarjanState {
1253 fn new(n: usize) -> Self {
1254 Self {
1255 index_counter: 0,
1256 indices: vec![u32::MAX; n],
1257 lowlinks: vec![0; n],
1258 on_stack: fixedbitset::FixedBitSet::with_capacity(n),
1259 stack: Vec::new(),
1260 sccs: Vec::new(),
1261 }
1262 }
1263
1264 fn discover(&mut self, node: usize) {
1266 self.indices[node] = self.index_counter;
1267 self.lowlinks[node] = self.index_counter;
1268 self.index_counter = self.index_counter.saturating_add(1);
1269 self.stack.push(node);
1270 self.on_stack.insert(node);
1271 }
1272
1273 fn step_successor(&mut self, frame: &mut TarjanFrame, adj: &[Vec<usize>]) -> Option<usize> {
1276 let v = frame.node;
1277 let w = adj[v][frame.next_succ];
1278 frame.next_succ = frame.next_succ.saturating_add(1);
1279 if self.indices[w] == u32::MAX {
1280 self.discover(w);
1281 Some(w)
1282 } else {
1283 if self.on_stack.contains(w) {
1284 self.lowlinks[v] = self.lowlinks[v].min(self.indices[w]);
1285 }
1286 None
1287 }
1288 }
1289
1290 fn finish_frame(&mut self, v: usize, parent: Option<usize>) {
1293 if self.lowlinks[v] == self.indices[v] {
1294 let mut scc = Vec::new();
1295 while let Some(w) = self.stack.pop() {
1296 self.on_stack.remove(w);
1297 scc.push(w);
1298 if w == v {
1299 break;
1300 }
1301 }
1302 self.sccs.push(scc);
1303 }
1304 if let Some(pv) = parent {
1305 self.lowlinks[pv] = self.lowlinks[pv].min(self.lowlinks[v]);
1306 }
1307 }
1308}
1309
1310fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
1314 let mut state = TarjanState::new(n);
1315
1316 for start in 0..n {
1317 if state.indices[start] != u32::MAX {
1318 continue;
1319 }
1320 state.discover(start);
1321 let mut dfs: Vec<TarjanFrame> = vec![TarjanFrame {
1322 node: start,
1323 next_succ: 0,
1324 }];
1325
1326 while let Some(frame) = dfs.last_mut() {
1327 let v = frame.node;
1328 if frame.next_succ < adj[v].len() {
1329 if let Some(child) = state.step_successor(frame, adj) {
1330 dfs.push(TarjanFrame {
1331 node: child,
1332 next_succ: 0,
1333 });
1334 }
1335 } else {
1336 dfs.pop();
1337 state.finish_frame(v, dfs.last().map(|parent| parent.node));
1338 }
1339 }
1340 }
1341
1342 state.sccs
1343}