Skip to main content

fallow_graph/graph/re_exports/
mod.rs

1//! Phase 4: Re-export chain resolution, propagate references through barrel files.
2
3mod 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/// A re-export cycle or self-loop detected during Phase 4 chain resolution.
23///
24/// The graph-layer mirror of `fallow_types::results::ReExportCycle`. Kept in
25/// the graph crate so the types crate does not need a dependency arrow back
26/// into graph for the conversion. The analysis backend performs the
27/// `GraphReExportCycle` to `ReExportCycle` mapping by reading `is_self_loop`
28/// and routing to the matching `ReExportCycleKind` variant.
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct GraphReExportCycle {
31    /// Member files participating in the cycle, sorted lexicographically by
32    /// the `Path::display()` form (matches the existing diagnostic-output
33    /// sort). For a self-loop, exactly one entry.
34    pub files: Vec<PathBuf>,
35    /// Parallel array to `files`: the FileId for each member. Kept alongside
36    /// the paths so the core-layer detector can call
37    /// `suppressions.is_file_suppressed(id, IssueKind::ReExportCycle)`
38    /// without an extra path-to-FileId lookup.
39    pub file_ids: Vec<FileId>,
40    /// `true` for single-file self-re-exports (`export * from './'`), `false`
41    /// for multi-node strongly connected components.
42    pub is_self_loop: bool,
43}
44
45/// A single re-export edge collected from the module graph.
46///
47/// Replaces an earlier ad-hoc 5-tuple so the propagation loop is more
48/// readable and the new `is_type_only` field carried into
49/// [`propagate_star_re_export`] does not get lost in tuple-index plumbing.
50struct ReExportTuple {
51    barrel: FileId,
52    source: FileId,
53    imported_name: String,
54    exported_name: String,
55    /// `true` when the triggering re-export edge is `export type * from ...`
56    /// or `export type { foo } from ...`. Threaded into star propagation so
57    /// any synthetic stub created on the source module reflects the chain's
58    /// type-only-ness instead of defaulting to `false`.
59    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    /// Resolve re-export chains: when module A re-exports from B,
73    /// any reference to A's re-exported symbol should also count as a reference
74    /// to B's original export (and transitively through the chain).
75    ///
76    /// Returns the list of re-export cycles and self-loops detected during
77    /// the upfront Tarjan SCC pass. The caller stores this on the
78    /// `ModuleGraph` so the `re-export-cycle` finding type can surface them
79    /// to users instead of relying on `RUST_LOG=warn` (see issue #515).
80    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    /// Flatten every module's re-export edges into a single tuple list.
113    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    /// Compute the transitive closure of `export *` source files reachable from
129    /// entry-point barrels.
130    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    /// Index every edge by its target file for fast star-propagation lookups.
163    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    /// Run the monotone fixpoint that propagates references through every chain.
188    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    /// Propagate references for one re-export edge, dispatching star vs named.
233    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
274/// Find SCCs of size >= 2 in the re-export subgraph and self-re-export
275/// edges, emit one `tracing::warn!` per cycle, AND return structured cycle
276/// data for the user-visible `re-export-cycle` finding type.
277///
278/// The `tracing::warn!` emissions remain unchanged from #442 (RUST_LOG=warn
279/// operators still see them). The returned `Vec<GraphReExportCycle>` is the
280/// structured surface that the analysis backend consumes and wraps in typed
281/// `ReExportCycleFinding`s for end-user output. See issue #515.
282fn 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
308/// Assign a dense node index to every distinct barrel / source file id.
309fn 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
326/// Build the adjacency list for the re-export subgraph, emitting a self-loop
327/// `GraphReExportCycle` for any barrel that re-exports from itself.
328fn 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
353/// Emit the `tracing::warn!` and structured cycle for a self-re-export edge.
354fn 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
373/// Emit the `tracing::warn!` and structured cycle for a multi-node SCC.
374fn 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
415/// Resolve a `FileId` to its `(PathBuf, display string)`, falling back to a
416/// placeholder when the id is outside the module list.
417fn 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
437/// Mutable Tarjan SCC state shared across the iterative DFS.
438struct 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    /// Assign the next DFS index to `node` and push it onto the SCC stack.
460    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    /// Advance one successor of the current frame, pushing a child frame when a
469    /// new node is discovered. Returns the child node to descend into, if any.
470    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    /// Finish the current frame: emit its SCC if it is a root, then propagate
486    /// its lowlink to the parent frame.
487    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
505/// Iterative Tarjan's strongly connected components, returns SCCs that
506/// contain at least one node. The graph is given as adjacency-by-index;
507/// the caller maps node indices back to FileIds.
508fn 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}