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::{propagate_named_re_export, propagate_star_re_export};
16
17/// A re-export cycle or self-loop detected during Phase 4 chain resolution.
18///
19/// The graph-layer mirror of `fallow_types::results::ReExportCycle`. Kept in
20/// the graph crate so the types crate does not need a dependency arrow back
21/// into graph for the conversion; `fallow_core::analyze::re_export_cycles`
22/// performs the `GraphReExportCycle` to `ReExportCycle` mapping by reading
23/// `is_self_loop` and routing to the matching `ReExportCycleKind` variant.
24#[derive(Debug, Clone)]
25pub struct GraphReExportCycle {
26    /// Member files participating in the cycle, sorted lexicographically by
27    /// the `Path::display()` form (matches the existing diagnostic-output
28    /// sort). For a self-loop, exactly one entry.
29    pub files: Vec<PathBuf>,
30    /// Parallel array to `files`: the FileId for each member. Kept alongside
31    /// the paths so the core-layer detector can call
32    /// `suppressions.is_file_suppressed(id, IssueKind::ReExportCycle)`
33    /// without an extra path-to-FileId lookup.
34    pub file_ids: Vec<FileId>,
35    /// `true` for single-file self-re-exports (`export * from './'`), `false`
36    /// for multi-node strongly connected components.
37    pub is_self_loop: bool,
38}
39
40/// A single re-export edge collected from the module graph.
41///
42/// Replaces an earlier ad-hoc 5-tuple so the propagation loop is more
43/// readable and the new `is_type_only` field carried into
44/// [`propagate_star_re_export`] does not get lost in tuple-index plumbing.
45struct ReExportTuple {
46    barrel: FileId,
47    source: FileId,
48    imported_name: String,
49    exported_name: String,
50    /// `true` when the triggering re-export edge is `export type * from ...`
51    /// or `export type { foo } from ...`. Threaded into star propagation so
52    /// any synthetic stub created on the source module reflects the chain's
53    /// type-only-ness instead of defaulting to `false`.
54    is_type_only: bool,
55}
56
57impl ModuleGraph {
58    /// Resolve re-export chains: when module A re-exports from B,
59    /// any reference to A's re-exported symbol should also count as a reference
60    /// to B's original export (and transitively through the chain).
61    ///
62    /// Returns the list of re-export cycles and self-loops detected during
63    /// the upfront Tarjan SCC pass. The caller stores this on the
64    /// `ModuleGraph` so the `re-export-cycle` finding type can surface them
65    /// to users instead of relying on `RUST_LOG=warn` (see issue #515).
66    pub(super) fn resolve_re_export_chains(&mut self) -> Vec<GraphReExportCycle> {
67        let re_export_info: Vec<ReExportTuple> = self
68            .modules
69            .iter()
70            .flat_map(|m| {
71                m.re_exports.iter().map(move |re| ReExportTuple {
72                    barrel: m.file_id,
73                    source: re.source_file,
74                    imported_name: re.imported_name.clone(),
75                    exported_name: re.exported_name.clone(),
76                    is_type_only: re.is_type_only,
77                })
78            })
79            .collect();
80
81        if re_export_info.is_empty() {
82            return Vec::new();
83        }
84
85        let cycles = find_re_export_cycles(&self.modules, &re_export_info);
86
87        let mut entry_star_targets: FxHashSet<FileId> = self
88            .modules
89            .iter()
90            .filter(|m| m.is_entry_point())
91            .flat_map(|m| {
92                m.re_exports
93                    .iter()
94                    .filter(|re| re.exported_name == "*")
95                    .map(|re| re.source_file)
96            })
97            .collect();
98        let mut entry_star_stack: Vec<FileId> = entry_star_targets.iter().copied().collect();
99        while let Some(file_id) = entry_star_stack.pop() {
100            let idx = file_id.0 as usize;
101            if idx >= self.modules.len() {
102                continue;
103            }
104
105            for re in self.modules[idx]
106                .re_exports
107                .iter()
108                .filter(|re| re.exported_name == "*")
109            {
110                if entry_star_targets.insert(re.source_file) {
111                    entry_star_stack.push(re.source_file);
112                }
113            }
114        }
115
116        let mut edges_by_target: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
117        for (idx, edge) in self.edges.iter().enumerate() {
118            edges_by_target.entry(edge.target).or_default().push(idx);
119        }
120
121        let safety_cap = re_export_info.len().saturating_add(1);
122        let mut changed = true;
123        let mut iteration: usize = 0;
124        let mut existing_refs: FxHashSet<FileId> = FxHashSet::default();
125        let mut synthetic_stubs: FxHashSet<(FileId, String)> = FxHashSet::default();
126
127        while changed && iteration < safety_cap {
128            changed = false;
129            iteration += 1;
130
131            for entry in &re_export_info {
132                let barrel_idx = entry.barrel.0 as usize;
133                let source_idx = entry.source.0 as usize;
134
135                if barrel_idx >= self.modules.len() || source_idx >= self.modules.len() {
136                    continue;
137                }
138
139                if entry.exported_name == "*" {
140                    changed |= propagate_star_re_export(
141                        &mut self.modules,
142                        &self.edges,
143                        &edges_by_target,
144                        entry.barrel,
145                        barrel_idx,
146                        entry.source,
147                        source_idx,
148                        &entry_star_targets,
149                        entry.is_type_only,
150                        &mut synthetic_stubs,
151                    );
152                } else {
153                    changed |= propagate_named_re_export(
154                        &mut self.modules,
155                        entry.barrel,
156                        barrel_idx,
157                        source_idx,
158                        &entry.imported_name,
159                        &entry.exported_name,
160                        &mut existing_refs,
161                    );
162                }
163            }
164        }
165
166        if iteration >= safety_cap && changed {
167            tracing::error!(
168                iterations = iteration,
169                safety_cap,
170                re_export_edges = re_export_info.len(),
171                "Re-export chain fixpoint exceeded safety cap; \
172                 propagation may be non-monotonic. Please file a bug at \
173                 https://github.com/fallow-rs/fallow/issues with the repro."
174            );
175        }
176
177        cycles
178    }
179}
180
181/// Find SCCs of size >= 2 in the re-export subgraph and self-re-export
182/// edges, emit one `tracing::warn!` per cycle, AND return structured cycle
183/// data for the user-visible `re-export-cycle` finding type.
184///
185/// The `tracing::warn!` emissions remain unchanged from #442 (RUST_LOG=warn
186/// operators still see them). The returned `Vec<GraphReExportCycle>` is the
187/// structured surface that `fallow_core::analyze::re_export_cycles` consumes
188/// and wraps in typed `ReExportCycleFinding`s for end-user output. See
189/// issue #515.
190fn find_re_export_cycles(
191    modules: &[super::types::ModuleNode],
192    re_export_info: &[ReExportTuple],
193) -> Vec<GraphReExportCycle> {
194    let mut cycles: Vec<GraphReExportCycle> = Vec::new();
195
196    let mut node_index: FxHashMap<FileId, usize> = FxHashMap::default();
197    let mut nodes: Vec<FileId> = Vec::new();
198    for entry in re_export_info {
199        for &id in &[entry.barrel, entry.source] {
200            node_index.entry(id).or_insert_with(|| {
201                let idx = nodes.len();
202                nodes.push(id);
203                idx
204            });
205        }
206    }
207
208    let n = nodes.len();
209    if n == 0 {
210        return cycles;
211    }
212
213    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
214    let mut seen_edge: FxHashSet<(usize, usize)> = FxHashSet::default();
215    let mut seen_self_loop: FxHashSet<FileId> = FxHashSet::default();
216    for entry in re_export_info {
217        let from = node_index[&entry.barrel];
218        let to = node_index[&entry.source];
219        if from == to {
220            if seen_self_loop.insert(entry.barrel) {
221                let i = entry.barrel.0 as usize;
222                let (path_buf, path_display) = if i < modules.len() {
223                    let p = modules[i].path.clone();
224                    let d = p.display().to_string();
225                    (p, d)
226                } else {
227                    (
228                        PathBuf::from(format!("<file id {i}>")),
229                        format!("<file id {i}>"),
230                    )
231                };
232                tracing::warn!(
233                    file = path_display.as_str(),
234                    "Re-export self-loop detected: this file re-exports from \
235                     itself. Chain propagation is structurally a no-op for \
236                     these edges. Inspect the barrel for an accidental \
237                     `export * from './<this-file>'` after a rename or move."
238                );
239                cycles.push(GraphReExportCycle {
240                    files: vec![path_buf],
241                    file_ids: vec![entry.barrel],
242                    is_self_loop: true,
243                });
244            }
245            continue;
246        }
247        if seen_edge.insert((from, to)) {
248            adj[from].push(to);
249        }
250    }
251
252    let sccs = tarjan_scc(n, &adj);
253
254    for scc in &sccs {
255        if scc.len() < 2 {
256            continue;
257        }
258        let mut triples: Vec<(PathBuf, String, FileId)> = scc
259            .iter()
260            .map(|&idx| {
261                let file_id = nodes[idx];
262                let i = file_id.0 as usize;
263                if i < modules.len() {
264                    let p = modules[i].path.clone();
265                    let d = p.display().to_string();
266                    (p, d, file_id)
267                } else {
268                    let placeholder = format!("<file id {i}>");
269                    (PathBuf::from(&placeholder), placeholder, file_id)
270                }
271            })
272            .collect();
273        triples.sort_by(|a, b| a.1.cmp(&b.1));
274        let members = triples
275            .iter()
276            .map(|(_, d, _)| d.as_str())
277            .collect::<Vec<_>>()
278            .join(" <-> ");
279        tracing::warn!(
280            cycle_size = scc.len(),
281            members = members.as_str(),
282            "Re-export cycle detected: chain propagation may be incomplete \
283             for symbols on this barrel loop. Break the cycle to restore \
284             full reachability analysis."
285        );
286        let (files, file_ids) = triples.into_iter().fold(
287            (Vec::new(), Vec::new()),
288            |(mut paths, mut ids), (p, _, id)| {
289                paths.push(p);
290                ids.push(id);
291                (paths, ids)
292            },
293        );
294        cycles.push(GraphReExportCycle {
295            files,
296            file_ids,
297            is_self_loop: false,
298        });
299    }
300
301    cycles
302}
303
304/// Iterative Tarjan's strongly connected components, returns SCCs that
305/// contain at least one node. The graph is given as adjacency-by-index;
306/// the caller maps node indices back to FileIds.
307fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
308    use fixedbitset::FixedBitSet;
309
310    let mut index_counter: u32 = 0;
311    let mut indices: Vec<u32> = vec![u32::MAX; n];
312    let mut lowlinks: Vec<u32> = vec![0; n];
313    let mut on_stack = FixedBitSet::with_capacity(n);
314    let mut stack: Vec<usize> = Vec::new();
315    let mut sccs: Vec<Vec<usize>> = Vec::new();
316
317    struct Frame {
318        node: usize,
319        next_succ: usize,
320    }
321
322    for start in 0..n {
323        if indices[start] != u32::MAX {
324            continue;
325        }
326        let mut dfs: Vec<Frame> = vec![Frame {
327            node: start,
328            next_succ: 0,
329        }];
330        indices[start] = index_counter;
331        lowlinks[start] = index_counter;
332        index_counter = index_counter.saturating_add(1);
333        stack.push(start);
334        on_stack.insert(start);
335
336        while let Some(frame) = dfs.last_mut() {
337            let v = frame.node;
338            if frame.next_succ < adj[v].len() {
339                let w = adj[v][frame.next_succ];
340                frame.next_succ = frame.next_succ.saturating_add(1);
341                if indices[w] == u32::MAX {
342                    indices[w] = index_counter;
343                    lowlinks[w] = index_counter;
344                    index_counter = index_counter.saturating_add(1);
345                    stack.push(w);
346                    on_stack.insert(w);
347                    dfs.push(Frame {
348                        node: w,
349                        next_succ: 0,
350                    });
351                } else if on_stack.contains(w) {
352                    lowlinks[v] = lowlinks[v].min(indices[w]);
353                }
354            } else {
355                if lowlinks[v] == indices[v] {
356                    let mut scc = Vec::new();
357                    while let Some(w) = stack.pop() {
358                        on_stack.remove(w);
359                        scc.push(w);
360                        if w == v {
361                            break;
362                        }
363                    }
364                    sccs.push(scc);
365                }
366                dfs.pop();
367                if let Some(parent) = dfs.last_mut() {
368                    let pv = parent.node;
369                    lowlinks[pv] = lowlinks[pv].min(lowlinks[v]);
370                }
371            }
372        }
373    }
374
375    sccs
376}