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