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 super::ModuleGraph;
14
15use propagate::{
16    NamedReExportPropagation, StarReExportPropagation, propagate_named_re_export,
17    propagate_star_re_export,
18};
19
20/// A re-export cycle or self-loop detected during Phase 4 chain resolution.
21///
22/// The graph-layer mirror of `fallow_types::results::ReExportCycle`. Kept in
23/// the graph crate so the types crate does not need a dependency arrow back
24/// into graph for the conversion; `fallow_core::analyze::re_export_cycles`
25/// performs the `GraphReExportCycle` to `ReExportCycle` mapping by reading
26/// `is_self_loop` and routing to the matching `ReExportCycleKind` variant.
27#[derive(Debug, Clone)]
28pub struct GraphReExportCycle {
29    /// Member files participating in the cycle, sorted lexicographically by
30    /// the `Path::display()` form (matches the existing diagnostic-output
31    /// sort). For a self-loop, exactly one entry.
32    pub files: Vec<PathBuf>,
33    /// Parallel array to `files`: the FileId for each member. Kept alongside
34    /// the paths so the core-layer detector can call
35    /// `suppressions.is_file_suppressed(id, IssueKind::ReExportCycle)`
36    /// without an extra path-to-FileId lookup.
37    pub file_ids: Vec<FileId>,
38    /// `true` for single-file self-re-exports (`export * from './'`), `false`
39    /// for multi-node strongly connected components.
40    pub is_self_loop: bool,
41}
42
43/// A single re-export edge collected from the module graph.
44///
45/// Replaces an earlier ad-hoc 5-tuple so the propagation loop is more
46/// readable and the new `is_type_only` field carried into
47/// [`propagate_star_re_export`] does not get lost in tuple-index plumbing.
48struct ReExportTuple {
49    barrel: FileId,
50    source: FileId,
51    imported_name: String,
52    exported_name: String,
53    /// `true` when the triggering re-export edge is `export type * from ...`
54    /// or `export type { foo } from ...`. Threaded into star propagation so
55    /// any synthetic stub created on the source module reflects the chain's
56    /// type-only-ness instead of defaulting to `false`.
57    is_type_only: bool,
58}
59
60impl ModuleGraph {
61    /// Resolve re-export chains: when module A re-exports from B,
62    /// any reference to A's re-exported symbol should also count as a reference
63    /// to B's original export (and transitively through the chain).
64    ///
65    /// Returns the list of re-export cycles and self-loops detected during
66    /// the upfront Tarjan SCC pass. The caller stores this on the
67    /// `ModuleGraph` so the `re-export-cycle` finding type can surface them
68    /// to users instead of relying on `RUST_LOG=warn` (see issue #515).
69    pub(super) fn resolve_re_export_chains(&mut self) -> Vec<GraphReExportCycle> {
70        let re_export_info = self.collect_re_export_tuples();
71
72        if re_export_info.is_empty() {
73            return Vec::new();
74        }
75
76        let cycles = find_re_export_cycles(&self.modules, &re_export_info);
77
78        let entry_star_targets = self.collect_entry_star_targets();
79        let edges_by_target = self.build_edges_by_target();
80
81        self.run_re_export_fixpoint(&re_export_info, &entry_star_targets, &edges_by_target);
82
83        cycles
84    }
85
86    /// Flatten every module's re-export edges into a single tuple list.
87    fn collect_re_export_tuples(&self) -> Vec<ReExportTuple> {
88        self.modules
89            .iter()
90            .flat_map(|m| {
91                m.re_exports.iter().map(move |re| ReExportTuple {
92                    barrel: m.file_id,
93                    source: re.source_file,
94                    imported_name: re.imported_name.clone(),
95                    exported_name: re.exported_name.clone(),
96                    is_type_only: re.is_type_only,
97                })
98            })
99            .collect()
100    }
101
102    /// Compute the transitive closure of `export *` source files reachable from
103    /// entry-point barrels.
104    fn collect_entry_star_targets(&self) -> FxHashSet<FileId> {
105        let mut entry_star_targets: FxHashSet<FileId> = self
106            .modules
107            .iter()
108            .filter(|m| m.is_entry_point())
109            .flat_map(|m| {
110                m.re_exports
111                    .iter()
112                    .filter(|re| re.exported_name == "*")
113                    .map(|re| re.source_file)
114            })
115            .collect();
116        let mut entry_star_stack: Vec<FileId> = entry_star_targets.iter().copied().collect();
117        while let Some(file_id) = entry_star_stack.pop() {
118            let idx = file_id.0 as usize;
119            if idx >= self.modules.len() {
120                continue;
121            }
122
123            for re in self.modules[idx]
124                .re_exports
125                .iter()
126                .filter(|re| re.exported_name == "*")
127            {
128                if entry_star_targets.insert(re.source_file) {
129                    entry_star_stack.push(re.source_file);
130                }
131            }
132        }
133        entry_star_targets
134    }
135
136    /// Index every edge by its target file for fast star-propagation lookups.
137    fn build_edges_by_target(&self) -> FxHashMap<FileId, Vec<usize>> {
138        let mut edges_by_target: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
139        for (idx, edge) in self.edges.iter().enumerate() {
140            edges_by_target.entry(edge.target).or_default().push(idx);
141        }
142        edges_by_target
143    }
144
145    /// Run the monotone fixpoint that propagates references through every chain.
146    fn run_re_export_fixpoint(
147        &mut self,
148        re_export_info: &[ReExportTuple],
149        entry_star_targets: &FxHashSet<FileId>,
150        edges_by_target: &FxHashMap<FileId, Vec<usize>>,
151    ) {
152        let safety_cap = re_export_info.len().saturating_add(1);
153        let mut changed = true;
154        let mut iteration: usize = 0;
155        let mut existing_refs: FxHashSet<FileId> = FxHashSet::default();
156        let mut synthetic_stubs: FxHashSet<(FileId, String)> = FxHashSet::default();
157
158        while changed && iteration < safety_cap {
159            changed = false;
160            iteration += 1;
161
162            for entry in re_export_info {
163                changed |= self.propagate_re_export_entry(
164                    entry,
165                    entry_star_targets,
166                    edges_by_target,
167                    &mut existing_refs,
168                    &mut synthetic_stubs,
169                );
170            }
171        }
172
173        if iteration >= safety_cap && changed {
174            tracing::error!(
175                iterations = iteration,
176                safety_cap,
177                re_export_edges = re_export_info.len(),
178                "Re-export chain fixpoint exceeded safety cap; \
179                 propagation may be non-monotonic. Please file a bug at \
180                 https://github.com/fallow-rs/fallow/issues with the repro."
181            );
182        }
183    }
184
185    /// Propagate references for one re-export edge, dispatching star vs named.
186    fn propagate_re_export_entry(
187        &mut self,
188        entry: &ReExportTuple,
189        entry_star_targets: &FxHashSet<FileId>,
190        edges_by_target: &FxHashMap<FileId, Vec<usize>>,
191        existing_refs: &mut FxHashSet<FileId>,
192        synthetic_stubs: &mut FxHashSet<(FileId, String)>,
193    ) -> bool {
194        let barrel_idx = entry.barrel.0 as usize;
195        let source_idx = entry.source.0 as usize;
196
197        if barrel_idx >= self.modules.len() || source_idx >= self.modules.len() {
198            return false;
199        }
200
201        if entry.exported_name == "*" {
202            propagate_star_re_export(StarReExportPropagation {
203                modules: &mut self.modules,
204                edges: &self.edges,
205                edges_by_target,
206                barrel_id: entry.barrel,
207                barrel_idx,
208                source_id: entry.source,
209                source_idx,
210                entry_star_targets,
211                triggering_is_type_only: entry.is_type_only,
212                synthetic_stubs,
213            })
214        } else {
215            propagate_named_re_export(NamedReExportPropagation {
216                modules: &mut self.modules,
217                barrel_id: entry.barrel,
218                barrel_idx,
219                source_idx,
220                imported_name: &entry.imported_name,
221                exported_name: &entry.exported_name,
222                existing_refs,
223            })
224        }
225    }
226}
227
228/// Find SCCs of size >= 2 in the re-export subgraph and self-re-export
229/// edges, emit one `tracing::warn!` per cycle, AND return structured cycle
230/// data for the user-visible `re-export-cycle` finding type.
231///
232/// The `tracing::warn!` emissions remain unchanged from #442 (RUST_LOG=warn
233/// operators still see them). The returned `Vec<GraphReExportCycle>` is the
234/// structured surface that `fallow_core::analyze::re_export_cycles` consumes
235/// and wraps in typed `ReExportCycleFinding`s for end-user output. See
236/// issue #515.
237fn find_re_export_cycles(
238    modules: &[super::types::ModuleNode],
239    re_export_info: &[ReExportTuple],
240) -> Vec<GraphReExportCycle> {
241    let mut cycles: Vec<GraphReExportCycle> = Vec::new();
242
243    let (node_index, nodes) = build_re_export_node_index(re_export_info);
244    let n = nodes.len();
245    if n == 0 {
246        return cycles;
247    }
248
249    let adj = build_re_export_adjacency(re_export_info, &node_index, modules, &mut cycles);
250
251    let sccs = tarjan_scc(n, &adj);
252
253    for scc in &sccs {
254        if scc.len() < 2 {
255            continue;
256        }
257        cycles.push(build_multi_node_cycle(scc, &nodes, modules));
258    }
259
260    cycles
261}
262
263/// Assign a dense node index to every distinct barrel / source file id.
264fn build_re_export_node_index(
265    re_export_info: &[ReExportTuple],
266) -> (FxHashMap<FileId, usize>, Vec<FileId>) {
267    let mut node_index: FxHashMap<FileId, usize> = FxHashMap::default();
268    let mut nodes: Vec<FileId> = Vec::new();
269    for entry in re_export_info {
270        for &id in &[entry.barrel, entry.source] {
271            node_index.entry(id).or_insert_with(|| {
272                let idx = nodes.len();
273                nodes.push(id);
274                idx
275            });
276        }
277    }
278    (node_index, nodes)
279}
280
281/// Build the adjacency list for the re-export subgraph, emitting a self-loop
282/// `GraphReExportCycle` for any barrel that re-exports from itself.
283fn build_re_export_adjacency(
284    re_export_info: &[ReExportTuple],
285    node_index: &FxHashMap<FileId, usize>,
286    modules: &[super::types::ModuleNode],
287    cycles: &mut Vec<GraphReExportCycle>,
288) -> Vec<Vec<usize>> {
289    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); node_index.len()];
290    let mut seen_edge: FxHashSet<(usize, usize)> = FxHashSet::default();
291    let mut seen_self_loop: FxHashSet<FileId> = FxHashSet::default();
292    for entry in re_export_info {
293        let from = node_index[&entry.barrel];
294        let to = node_index[&entry.source];
295        if from == to {
296            if seen_self_loop.insert(entry.barrel) {
297                cycles.push(build_self_loop_cycle(entry.barrel, modules));
298            }
299            continue;
300        }
301        if seen_edge.insert((from, to)) {
302            adj[from].push(to);
303        }
304    }
305    adj
306}
307
308/// Emit the `tracing::warn!` and structured cycle for a self-re-export edge.
309fn build_self_loop_cycle(
310    barrel: FileId,
311    modules: &[super::types::ModuleNode],
312) -> GraphReExportCycle {
313    let (path_buf, path_display) = module_path_and_display(barrel, modules);
314    tracing::warn!(
315        file = path_display.as_str(),
316        "Re-export self-loop detected: this file re-exports from \
317         itself. Chain propagation is structurally a no-op for \
318         these edges. Inspect the barrel for an accidental \
319         `export * from './<this-file>'` after a rename or move."
320    );
321    GraphReExportCycle {
322        files: vec![path_buf],
323        file_ids: vec![barrel],
324        is_self_loop: true,
325    }
326}
327
328/// Emit the `tracing::warn!` and structured cycle for a multi-node SCC.
329fn build_multi_node_cycle(
330    scc: &[usize],
331    nodes: &[FileId],
332    modules: &[super::types::ModuleNode],
333) -> GraphReExportCycle {
334    let mut triples: Vec<(PathBuf, String, FileId)> = scc
335        .iter()
336        .map(|&idx| {
337            let file_id = nodes[idx];
338            let (path, display) = module_path_and_display(file_id, modules);
339            (path, display, file_id)
340        })
341        .collect();
342    triples.sort_by(|a, b| a.1.cmp(&b.1));
343    let members = triples
344        .iter()
345        .map(|(_, d, _)| d.as_str())
346        .collect::<Vec<_>>()
347        .join(" <-> ");
348    tracing::warn!(
349        cycle_size = scc.len(),
350        members = members.as_str(),
351        "Re-export cycle detected: chain propagation may be incomplete \
352         for symbols on this barrel loop. Break the cycle to restore \
353         full reachability analysis."
354    );
355    let (files, file_ids) = triples.into_iter().fold(
356        (Vec::new(), Vec::new()),
357        |(mut paths, mut ids), (p, _, id)| {
358            paths.push(p);
359            ids.push(id);
360            (paths, ids)
361        },
362    );
363    GraphReExportCycle {
364        files,
365        file_ids,
366        is_self_loop: false,
367    }
368}
369
370/// Resolve a `FileId` to its `(PathBuf, display string)`, falling back to a
371/// placeholder when the id is outside the module list.
372fn module_path_and_display(
373    file_id: FileId,
374    modules: &[super::types::ModuleNode],
375) -> (PathBuf, String) {
376    let i = file_id.0 as usize;
377    if i < modules.len() {
378        let p = modules[i].path.clone();
379        let d = p.display().to_string();
380        (p, d)
381    } else {
382        let placeholder = format!("<file id {i}>");
383        (PathBuf::from(&placeholder), placeholder)
384    }
385}
386
387struct TarjanFrame {
388    node: usize,
389    next_succ: usize,
390}
391
392/// Mutable Tarjan SCC state shared across the iterative DFS.
393struct TarjanState {
394    index_counter: u32,
395    indices: Vec<u32>,
396    lowlinks: Vec<u32>,
397    on_stack: fixedbitset::FixedBitSet,
398    stack: Vec<usize>,
399    sccs: Vec<Vec<usize>>,
400}
401
402impl TarjanState {
403    fn new(n: usize) -> Self {
404        Self {
405            index_counter: 0,
406            indices: vec![u32::MAX; n],
407            lowlinks: vec![0; n],
408            on_stack: fixedbitset::FixedBitSet::with_capacity(n),
409            stack: Vec::new(),
410            sccs: Vec::new(),
411        }
412    }
413
414    /// Assign the next DFS index to `node` and push it onto the SCC stack.
415    fn discover(&mut self, node: usize) {
416        self.indices[node] = self.index_counter;
417        self.lowlinks[node] = self.index_counter;
418        self.index_counter = self.index_counter.saturating_add(1);
419        self.stack.push(node);
420        self.on_stack.insert(node);
421    }
422
423    /// Advance one successor of the current frame, pushing a child frame when a
424    /// new node is discovered. Returns the child node to descend into, if any.
425    fn step_successor(&mut self, frame: &mut TarjanFrame, adj: &[Vec<usize>]) -> Option<usize> {
426        let v = frame.node;
427        let w = adj[v][frame.next_succ];
428        frame.next_succ = frame.next_succ.saturating_add(1);
429        if self.indices[w] == u32::MAX {
430            self.discover(w);
431            Some(w)
432        } else {
433            if self.on_stack.contains(w) {
434                self.lowlinks[v] = self.lowlinks[v].min(self.indices[w]);
435            }
436            None
437        }
438    }
439
440    /// Finish the current frame: emit its SCC if it is a root, then propagate
441    /// its lowlink to the parent frame.
442    fn finish_frame(&mut self, v: usize, parent: Option<usize>) {
443        if self.lowlinks[v] == self.indices[v] {
444            let mut scc = Vec::new();
445            while let Some(w) = self.stack.pop() {
446                self.on_stack.remove(w);
447                scc.push(w);
448                if w == v {
449                    break;
450                }
451            }
452            self.sccs.push(scc);
453        }
454        if let Some(pv) = parent {
455            self.lowlinks[pv] = self.lowlinks[pv].min(self.lowlinks[v]);
456        }
457    }
458}
459
460/// Iterative Tarjan's strongly connected components, returns SCCs that
461/// contain at least one node. The graph is given as adjacency-by-index;
462/// the caller maps node indices back to FileIds.
463fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
464    let mut state = TarjanState::new(n);
465
466    for start in 0..n {
467        if state.indices[start] != u32::MAX {
468            continue;
469        }
470        state.discover(start);
471        let mut dfs: Vec<TarjanFrame> = vec![TarjanFrame {
472            node: start,
473            next_succ: 0,
474        }];
475
476        while let Some(frame) = dfs.last_mut() {
477            let v = frame.node;
478            if frame.next_succ < adj[v].len() {
479                if let Some(child) = state.step_successor(frame, adj) {
480                    dfs.push(TarjanFrame {
481                        node: child,
482                        next_succ: 0,
483                    });
484                }
485            } else {
486                dfs.pop();
487                state.finish_frame(v, dfs.last().map(|parent| parent.node));
488            }
489        }
490    }
491
492    state.sccs
493}