fallow_graph/graph/re_exports/
mod.rs1mod 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#[derive(Debug, Clone)]
28pub struct GraphReExportCycle {
29 pub files: Vec<PathBuf>,
33 pub file_ids: Vec<FileId>,
38 pub is_self_loop: bool,
41}
42
43struct ReExportTuple {
49 barrel: FileId,
50 source: FileId,
51 imported_name: String,
52 exported_name: String,
53 is_type_only: bool,
58}
59
60impl ModuleGraph {
61 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
184fn 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
307fn 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}