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: Vec<ReExportTuple> = self
71            .modules
72            .iter()
73            .flat_map(|m| {
74                m.re_exports.iter().map(move |re| ReExportTuple {
75                    barrel: m.file_id,
76                    source: re.source_file,
77                    imported_name: re.imported_name.clone(),
78                    exported_name: re.exported_name.clone(),
79                    is_type_only: re.is_type_only,
80                })
81            })
82            .collect();
83
84        if re_export_info.is_empty() {
85            return Vec::new();
86        }
87
88        let cycles = find_re_export_cycles(&self.modules, &re_export_info);
89
90        let mut entry_star_targets: FxHashSet<FileId> = self
91            .modules
92            .iter()
93            .filter(|m| m.is_entry_point())
94            .flat_map(|m| {
95                m.re_exports
96                    .iter()
97                    .filter(|re| re.exported_name == "*")
98                    .map(|re| re.source_file)
99            })
100            .collect();
101        let mut entry_star_stack: Vec<FileId> = entry_star_targets.iter().copied().collect();
102        while let Some(file_id) = entry_star_stack.pop() {
103            let idx = file_id.0 as usize;
104            if idx >= self.modules.len() {
105                continue;
106            }
107
108            for re in self.modules[idx]
109                .re_exports
110                .iter()
111                .filter(|re| re.exported_name == "*")
112            {
113                if entry_star_targets.insert(re.source_file) {
114                    entry_star_stack.push(re.source_file);
115                }
116            }
117        }
118
119        let mut edges_by_target: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
120        for (idx, edge) in self.edges.iter().enumerate() {
121            edges_by_target.entry(edge.target).or_default().push(idx);
122        }
123
124        let safety_cap = re_export_info.len().saturating_add(1);
125        let mut changed = true;
126        let mut iteration: usize = 0;
127        let mut existing_refs: FxHashSet<FileId> = FxHashSet::default();
128        let mut synthetic_stubs: FxHashSet<(FileId, String)> = FxHashSet::default();
129
130        while changed && iteration < safety_cap {
131            changed = false;
132            iteration += 1;
133
134            for entry in &re_export_info {
135                let barrel_idx = entry.barrel.0 as usize;
136                let source_idx = entry.source.0 as usize;
137
138                if barrel_idx >= self.modules.len() || source_idx >= self.modules.len() {
139                    continue;
140                }
141
142                if entry.exported_name == "*" {
143                    changed |= propagate_star_re_export(StarReExportPropagation {
144                        modules: &mut self.modules,
145                        edges: &self.edges,
146                        edges_by_target: &edges_by_target,
147                        barrel_id: entry.barrel,
148                        barrel_idx,
149                        source_id: entry.source,
150                        source_idx,
151                        entry_star_targets: &entry_star_targets,
152                        triggering_is_type_only: entry.is_type_only,
153                        synthetic_stubs: &mut synthetic_stubs,
154                    });
155                } else {
156                    changed |= propagate_named_re_export(NamedReExportPropagation {
157                        modules: &mut self.modules,
158                        barrel_id: entry.barrel,
159                        barrel_idx,
160                        source_idx,
161                        imported_name: &entry.imported_name,
162                        exported_name: &entry.exported_name,
163                        existing_refs: &mut existing_refs,
164                    });
165                }
166            }
167        }
168
169        if iteration >= safety_cap && changed {
170            tracing::error!(
171                iterations = iteration,
172                safety_cap,
173                re_export_edges = re_export_info.len(),
174                "Re-export chain fixpoint exceeded safety cap; \
175                 propagation may be non-monotonic. Please file a bug at \
176                 https://github.com/fallow-rs/fallow/issues with the repro."
177            );
178        }
179
180        cycles
181    }
182}
183
184/// Find SCCs of size >= 2 in the re-export subgraph and self-re-export
185/// edges, emit one `tracing::warn!` per cycle, AND return structured cycle
186/// data for the user-visible `re-export-cycle` finding type.
187///
188/// The `tracing::warn!` emissions remain unchanged from #442 (RUST_LOG=warn
189/// operators still see them). The returned `Vec<GraphReExportCycle>` is the
190/// structured surface that `fallow_core::analyze::re_export_cycles` consumes
191/// and wraps in typed `ReExportCycleFinding`s for end-user output. See
192/// issue #515.
193fn find_re_export_cycles(
194    modules: &[super::types::ModuleNode],
195    re_export_info: &[ReExportTuple],
196) -> Vec<GraphReExportCycle> {
197    let mut cycles: Vec<GraphReExportCycle> = Vec::new();
198
199    let mut node_index: FxHashMap<FileId, usize> = FxHashMap::default();
200    let mut nodes: Vec<FileId> = Vec::new();
201    for entry in re_export_info {
202        for &id in &[entry.barrel, entry.source] {
203            node_index.entry(id).or_insert_with(|| {
204                let idx = nodes.len();
205                nodes.push(id);
206                idx
207            });
208        }
209    }
210
211    let n = nodes.len();
212    if n == 0 {
213        return cycles;
214    }
215
216    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
217    let mut seen_edge: FxHashSet<(usize, usize)> = FxHashSet::default();
218    let mut seen_self_loop: FxHashSet<FileId> = FxHashSet::default();
219    for entry in re_export_info {
220        let from = node_index[&entry.barrel];
221        let to = node_index[&entry.source];
222        if from == to {
223            if seen_self_loop.insert(entry.barrel) {
224                let i = entry.barrel.0 as usize;
225                let (path_buf, path_display) = if i < modules.len() {
226                    let p = modules[i].path.clone();
227                    let d = p.display().to_string();
228                    (p, d)
229                } else {
230                    (
231                        PathBuf::from(format!("<file id {i}>")),
232                        format!("<file id {i}>"),
233                    )
234                };
235                tracing::warn!(
236                    file = path_display.as_str(),
237                    "Re-export self-loop detected: this file re-exports from \
238                     itself. Chain propagation is structurally a no-op for \
239                     these edges. Inspect the barrel for an accidental \
240                     `export * from './<this-file>'` after a rename or move."
241                );
242                cycles.push(GraphReExportCycle {
243                    files: vec![path_buf],
244                    file_ids: vec![entry.barrel],
245                    is_self_loop: true,
246                });
247            }
248            continue;
249        }
250        if seen_edge.insert((from, to)) {
251            adj[from].push(to);
252        }
253    }
254
255    let sccs = tarjan_scc(n, &adj);
256
257    for scc in &sccs {
258        if scc.len() < 2 {
259            continue;
260        }
261        let mut triples: Vec<(PathBuf, String, FileId)> = scc
262            .iter()
263            .map(|&idx| {
264                let file_id = nodes[idx];
265                let i = file_id.0 as usize;
266                if i < modules.len() {
267                    let p = modules[i].path.clone();
268                    let d = p.display().to_string();
269                    (p, d, file_id)
270                } else {
271                    let placeholder = format!("<file id {i}>");
272                    (PathBuf::from(&placeholder), placeholder, file_id)
273                }
274            })
275            .collect();
276        triples.sort_by(|a, b| a.1.cmp(&b.1));
277        let members = triples
278            .iter()
279            .map(|(_, d, _)| d.as_str())
280            .collect::<Vec<_>>()
281            .join(" <-> ");
282        tracing::warn!(
283            cycle_size = scc.len(),
284            members = members.as_str(),
285            "Re-export cycle detected: chain propagation may be incomplete \
286             for symbols on this barrel loop. Break the cycle to restore \
287             full reachability analysis."
288        );
289        let (files, file_ids) = triples.into_iter().fold(
290            (Vec::new(), Vec::new()),
291            |(mut paths, mut ids), (p, _, id)| {
292                paths.push(p);
293                ids.push(id);
294                (paths, ids)
295            },
296        );
297        cycles.push(GraphReExportCycle {
298            files,
299            file_ids,
300            is_self_loop: false,
301        });
302    }
303
304    cycles
305}
306
307/// Iterative Tarjan's strongly connected components, returns SCCs that
308/// contain at least one node. The graph is given as adjacency-by-index;
309/// the caller maps node indices back to FileIds.
310fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
311    use fixedbitset::FixedBitSet;
312
313    let mut index_counter: u32 = 0;
314    let mut indices: Vec<u32> = vec![u32::MAX; n];
315    let mut lowlinks: Vec<u32> = vec![0; n];
316    let mut on_stack = FixedBitSet::with_capacity(n);
317    let mut stack: Vec<usize> = Vec::new();
318    let mut sccs: Vec<Vec<usize>> = Vec::new();
319
320    struct Frame {
321        node: usize,
322        next_succ: usize,
323    }
324
325    for start in 0..n {
326        if indices[start] != u32::MAX {
327            continue;
328        }
329        let mut dfs: Vec<Frame> = vec![Frame {
330            node: start,
331            next_succ: 0,
332        }];
333        indices[start] = index_counter;
334        lowlinks[start] = index_counter;
335        index_counter = index_counter.saturating_add(1);
336        stack.push(start);
337        on_stack.insert(start);
338
339        while let Some(frame) = dfs.last_mut() {
340            let v = frame.node;
341            if frame.next_succ < adj[v].len() {
342                let w = adj[v][frame.next_succ];
343                frame.next_succ = frame.next_succ.saturating_add(1);
344                if indices[w] == u32::MAX {
345                    indices[w] = index_counter;
346                    lowlinks[w] = index_counter;
347                    index_counter = index_counter.saturating_add(1);
348                    stack.push(w);
349                    on_stack.insert(w);
350                    dfs.push(Frame {
351                        node: w,
352                        next_succ: 0,
353                    });
354                } else if on_stack.contains(w) {
355                    lowlinks[v] = lowlinks[v].min(indices[w]);
356                }
357            } else {
358                if lowlinks[v] == indices[v] {
359                    let mut scc = Vec::new();
360                    while let Some(w) = stack.pop() {
361                        on_stack.remove(w);
362                        scc.push(w);
363                        if w == v {
364                            break;
365                        }
366                    }
367                    sccs.push(scc);
368                }
369                dfs.pop();
370                if let Some(parent) = dfs.last_mut() {
371                    let pv = parent.node;
372                    lowlinks[pv] = lowlinks[pv].min(lowlinks[v]);
373                }
374            }
375        }
376    }
377
378    sccs
379}