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::collections::VecDeque;
8use std::path::PathBuf;
9
10use rustc_hash::{FxHashMap, FxHashSet};
11
12#[cfg(test)]
13use std::cell::{Cell, RefCell};
14
15use crate::resolve::ResolvedModule;
16use fallow_types::discover::FileId;
17
18use super::types::{ReferencePathInterner, RoutedReferenceKey};
19use super::{Edge, ModuleGraph};
20
21use propagate::{
22    EffectiveDeclarationRouteCache, ImportBindingUsageIndex, NamedPropagationScratch,
23    NamedReExportPropagation, StarReExportPropagation, propagate_named_re_export,
24    propagate_star_re_export,
25};
26
27#[cfg(test)]
28thread_local! {
29    static PROPAGATION_VISITS: RefCell<Option<Vec<(FileId, FileId)>>> =
30        const { RefCell::new(None) };
31    static DIFFERENTIAL_CHECK_ENABLED: Cell<bool> = const { Cell::new(false) };
32}
33
34#[cfg(test)]
35fn record_propagation_visit(entry: &ReExportTuple) {
36    PROPAGATION_VISITS.with(|visits| {
37        if let Some(visits) = visits.borrow_mut().as_mut() {
38            visits.push((entry.barrel, entry.source));
39        }
40    });
41}
42
43#[cfg(test)]
44fn capture_propagation_visits<T>(run: impl FnOnce() -> T) -> (T, Vec<(FileId, FileId)>) {
45    PROPAGATION_VISITS.with(|visits| *visits.borrow_mut() = Some(Vec::new()));
46    let result = run();
47    let visits = PROPAGATION_VISITS.with(|visits| visits.borrow_mut().take().unwrap_or_default());
48    (result, visits)
49}
50
51#[cfg(test)]
52fn with_re_export_differential_check<T>(run: impl FnOnce() -> T) -> T {
53    DIFFERENTIAL_CHECK_ENABLED.with(|enabled| {
54        let previous = enabled.replace(true);
55        let result = run();
56        enabled.set(previous);
57        result
58    })
59}
60
61/// A re-export cycle or self-loop detected during Phase 4 chain resolution.
62///
63/// The graph-layer mirror of `fallow_types::results::ReExportCycle`. Kept in
64/// the graph crate so the types crate does not need a dependency arrow back
65/// into graph for the conversion. The analysis backend performs the
66/// `GraphReExportCycle` to `ReExportCycle` mapping by reading `is_self_loop`
67/// and routing to the matching `ReExportCycleKind` variant.
68#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
69pub struct GraphReExportCycle {
70    /// Member files participating in the cycle, sorted lexicographically by
71    /// the `Path::display()` form (matches the existing diagnostic-output
72    /// sort). For a self-loop, exactly one entry.
73    pub files: Vec<PathBuf>,
74    /// Parallel array to `files`: the FileId for each member. Kept alongside
75    /// the paths so the core-layer detector can call
76    /// `suppressions.is_file_suppressed(id, IssueKind::ReExportCycle)`
77    /// without an extra path-to-FileId lookup.
78    pub file_ids: Vec<FileId>,
79    /// `true` for single-file self-re-exports (`export * from './'`), `false`
80    /// for multi-node strongly connected components.
81    pub is_self_loop: bool,
82}
83
84/// A single re-export edge collected from the module graph.
85///
86/// Replaces an earlier ad-hoc 5-tuple so the propagation loop is more
87/// readable and the new `is_type_only` field carried into
88/// [`propagate_star_re_export`] does not get lost in tuple-index plumbing.
89struct ReExportTuple {
90    barrel: FileId,
91    source: FileId,
92    imported_name: String,
93    exported_name: String,
94    /// `true` when the triggering re-export edge is `export type * from ...`
95    /// or `export type { foo } from ...`. Threaded into star propagation so
96    /// any synthetic stub created on the source module reflects the chain's
97    /// type-only-ness instead of defaulting to `false`.
98    is_type_only: bool,
99}
100
101struct ReExportContext<'a> {
102    entry_star_targets: &'a FxHashSet<FileId>,
103    edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
104    binding_usage: &'a ImportBindingUsageIndex,
105    effective_exports: &'a super::effective_exports::EffectiveExportIndex,
106    existing_refs: &'a mut FxHashSet<RoutedReferenceKey>,
107    synthetic_stubs: &'a mut FxHashSet<(FileId, String, bool)>,
108    declaration_routes: &'a mut EffectiveDeclarationRouteCache,
109    scratch: &'a mut NamedPropagationScratch,
110    reference_paths: &'a mut ReferencePathInterner,
111}
112
113struct ReExportFixpointInput<'a> {
114    re_export_info: &'a [ReExportTuple],
115    entry_star_targets: &'a FxHashSet<FileId>,
116    edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
117    module_by_id: &'a FxHashMap<FileId, &'a ResolvedModule>,
118    reference_paths: &'a mut ReferencePathInterner,
119}
120
121#[cfg(test)]
122struct LegacyReExportFullScan<'a> {
123    modules: &'a mut [super::types::ModuleNode],
124    edges: &'a [Edge],
125    re_export_info: &'a [ReExportTuple],
126    entry_star_targets: &'a FxHashSet<FileId>,
127    edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
128    module_by_id: &'a FxHashMap<FileId, &'a ResolvedModule>,
129    effective_exports: &'a super::effective_exports::EffectiveExportIndex,
130    reference_paths: &'a mut ReferencePathInterner,
131}
132
133/// Deterministic scheduler for monotone re-export propagation.
134///
135/// Each tuple reads export state from `barrel` and may add references or
136/// synthetic exports to `source`. When `source` changes, only tuples whose
137/// `barrel` is that module can observe the new state, so those tuple indices
138/// are re-enqueued in their original stable order.
139struct ReExportPropagationPlan {
140    observers_by_module: FxHashMap<FileId, Vec<usize>>,
141    queue: VecDeque<usize>,
142    enqueued: Vec<bool>,
143}
144
145impl ReExportPropagationPlan {
146    fn new(re_export_info: &[ReExportTuple]) -> Self {
147        let mut observers_by_module: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
148        for (idx, entry) in re_export_info.iter().enumerate() {
149            observers_by_module
150                .entry(entry.barrel)
151                .or_default()
152                .push(idx);
153        }
154
155        Self {
156            observers_by_module,
157            queue: (0..re_export_info.len()).collect(),
158            enqueued: vec![true; re_export_info.len()],
159        }
160    }
161
162    fn pop_front(&mut self) -> Option<usize> {
163        let idx = self.queue.pop_front()?;
164        self.enqueued[idx] = false;
165        Some(idx)
166    }
167
168    fn enqueue_observers(&mut self, changed_module: FileId) {
169        let Some(observers) = self.observers_by_module.get(&changed_module) else {
170            return;
171        };
172        for &idx in observers {
173            if !self.enqueued[idx] {
174                self.enqueued[idx] = true;
175                self.queue.push_back(idx);
176            }
177        }
178    }
179}
180
181impl ModuleGraph {
182    /// Resolve re-export chains: when module A re-exports from B,
183    /// any reference to A's re-exported symbol should also count as a reference
184    /// to B's original export (and transitively through the chain).
185    ///
186    /// Returns the list of re-export cycles and self-loops detected during
187    /// the upfront Tarjan SCC pass. The caller stores this on the
188    /// `ModuleGraph` so the `re-export-cycle` finding type can surface them
189    /// to users instead of relying on `RUST_LOG=warn` (see issue #515).
190    pub(super) fn resolve_re_export_chains(
191        &mut self,
192        module_by_id: &FxHashMap<FileId, &ResolvedModule>,
193        reference_paths: &mut ReferencePathInterner,
194    ) -> Vec<GraphReExportCycle> {
195        let re_export_info = self.collect_re_export_tuples();
196
197        if re_export_info.is_empty() {
198            return Vec::new();
199        }
200
201        let cycles = find_re_export_cycles(&self.modules, &re_export_info);
202
203        let entry_star_targets = self.collect_entry_star_targets();
204        let edges_by_target = self.build_edges_by_target();
205
206        self.run_re_export_fixpoint(ReExportFixpointInput {
207            re_export_info: &re_export_info,
208            entry_star_targets: &entry_star_targets,
209            edges_by_target: &edges_by_target,
210            module_by_id,
211            reference_paths,
212        });
213
214        cycles
215    }
216
217    /// Flatten every module's re-export edges into a single tuple list.
218    fn collect_re_export_tuples(&self) -> Vec<ReExportTuple> {
219        self.modules
220            .iter()
221            .flat_map(|m| {
222                m.re_exports.iter().map(move |re| ReExportTuple {
223                    barrel: m.file_id,
224                    source: re.source_file,
225                    imported_name: re.imported_name.clone(),
226                    exported_name: re.exported_name.clone(),
227                    is_type_only: re.is_type_only,
228                })
229            })
230            .collect()
231    }
232
233    /// Compute the transitive closure of `export *` source files reachable from
234    /// entry-point barrels.
235    fn collect_entry_star_targets(&self) -> FxHashSet<FileId> {
236        let mut entry_star_targets: FxHashSet<FileId> = self
237            .modules
238            .iter()
239            .filter(|m| m.is_entry_point())
240            .flat_map(|m| {
241                m.re_exports
242                    .iter()
243                    .filter(|re| re.exported_name == "*")
244                    .map(|re| re.source_file)
245            })
246            .collect();
247        let mut entry_star_stack: Vec<FileId> = entry_star_targets.iter().copied().collect();
248        while let Some(file_id) = entry_star_stack.pop() {
249            let idx = file_id.0 as usize;
250            if idx >= self.modules.len() {
251                continue;
252            }
253
254            for re in self.modules[idx]
255                .re_exports
256                .iter()
257                .filter(|re| re.exported_name == "*")
258            {
259                if entry_star_targets.insert(re.source_file) {
260                    entry_star_stack.push(re.source_file);
261                }
262            }
263        }
264        entry_star_targets
265    }
266
267    /// Index every edge by its target file for fast star-propagation lookups.
268    fn build_edges_by_target(&self) -> FxHashMap<FileId, Vec<usize>> {
269        let mut edges_by_target: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
270        for (idx, edge) in self.edges.iter().enumerate() {
271            edges_by_target.entry(edge.target).or_default().push(idx);
272        }
273        edges_by_target
274    }
275
276    /// Run monotone propagation, revisiting only tuples affected by new state.
277    fn run_re_export_fixpoint(&mut self, input: ReExportFixpointInput<'_>) {
278        let ReExportFixpointInput {
279            re_export_info,
280            entry_star_targets,
281            edges_by_target,
282            module_by_id,
283            reference_paths,
284        } = input;
285        #[cfg(test)]
286        let mut legacy_modules: Option<Vec<super::types::ModuleNode>> = DIFFERENTIAL_CHECK_ENABLED
287            .with(|enabled| {
288                enabled.get().then(|| {
289                    serde_json::from_value(
290                        serde_json::to_value(&self.modules)
291                            .expect("module graph should serialize for differential testing"),
292                    )
293                    .expect("module graph should deserialize for differential testing")
294                })
295            });
296
297        let safety_cap = self.re_export_transition_safety_cap(re_export_info);
298        let mut processed = 0usize;
299        let mut plan = ReExportPropagationPlan::new(re_export_info);
300        let mut existing_refs: FxHashSet<RoutedReferenceKey> = FxHashSet::default();
301        let mut synthetic_stubs: FxHashSet<(FileId, String, bool)> = FxHashSet::default();
302        let binding_usage = ImportBindingUsageIndex::build(module_by_id);
303        let mut declaration_routes = EffectiveDeclarationRouteCache::default();
304        let mut scratch = NamedPropagationScratch::default();
305
306        while let Some(entry_idx) = plan.pop_front() {
307            if processed >= safety_cap {
308                tracing::error!(
309                    processed,
310                    safety_cap,
311                    re_export_edges = re_export_info.len(),
312                    "Re-export propagation exceeded its finite-state safety cap; \
313                     propagation may be non-monotonic. Please file a bug at \
314                     https://github.com/fallow-rs/fallow/issues with the repro."
315                );
316                break;
317            }
318            processed += 1;
319
320            let mut context = ReExportContext {
321                entry_star_targets,
322                edges_by_target,
323                binding_usage: &binding_usage,
324                effective_exports: &self.effective_exports,
325                existing_refs: &mut existing_refs,
326                synthetic_stubs: &mut synthetic_stubs,
327                declaration_routes: &mut declaration_routes,
328                scratch: &mut scratch,
329                reference_paths,
330            };
331
332            let entry = &re_export_info[entry_idx];
333            #[cfg(test)]
334            record_propagation_visit(entry);
335            if Self::propagate_re_export_entry(&mut self.modules, &self.edges, entry, &mut context)
336            {
337                plan.enqueue_observers(entry.source);
338            }
339        }
340
341        #[cfg(test)]
342        if let Some(legacy_modules) = legacy_modules.as_mut() {
343            Self::run_re_export_full_scan(LegacyReExportFullScan {
344                modules: legacy_modules,
345                edges: &self.edges,
346                re_export_info,
347                entry_star_targets,
348                edges_by_target,
349                module_by_id,
350                effective_exports: &self.effective_exports,
351                reference_paths,
352            });
353            assert_eq!(
354                serde_json::to_value(legacy_modules)
355                    .expect("legacy module graph should serialize for comparison"),
356                serde_json::to_value(&self.modules)
357                    .expect("queue module graph should serialize for comparison"),
358                "work-queue propagation must match the legacy full-scan fixpoint"
359            );
360        }
361    }
362
363    /// Bound scheduler work by the finite set of exports, synthetic names, and
364    /// interned reference paths that monotone propagation can add.
365    fn re_export_transition_safety_cap(&self, re_export_info: &[ReExportTuple]) -> usize {
366        let initial_exports = self
367            .modules
368            .iter()
369            .map(|module| module.exports.len())
370            .sum::<usize>();
371        let named_inputs = self
372            .edges
373            .iter()
374            .flat_map(|edge| &edge.symbols)
375            .filter(|symbol| {
376                matches!(
377                    &symbol.imported_name,
378                    fallow_types::extract::ImportedName::Named(_)
379                )
380            })
381            .count()
382            .saturating_add(initial_exports)
383            .saturating_add(re_export_info.len());
384
385        let module_count = self.modules.len();
386        let synthetic_export_hosts = self
387            .modules
388            .iter()
389            .filter(|module| {
390                module
391                    .re_exports
392                    .iter()
393                    .any(|re_export| re_export.exported_name == "*")
394            })
395            .count();
396        let synthetic_exports = synthetic_export_hosts
397            .saturating_mul(named_inputs)
398            .saturating_mul(2);
399        let max_exports = initial_exports.saturating_add(synthetic_exports);
400        let reference_additions = max_exports.saturating_mul(module_count).saturating_mul(2);
401        let state_changes = synthetic_exports.saturating_add(reference_additions);
402
403        re_export_info
404            .len()
405            .saturating_add(state_changes.saturating_mul(re_export_info.len()))
406            .max(re_export_info.len())
407    }
408
409    /// Propagate references for one re-export edge, dispatching star vs named.
410    fn propagate_re_export_entry(
411        modules: &mut [super::types::ModuleNode],
412        edges: &[Edge],
413        entry: &ReExportTuple,
414        context: &mut ReExportContext<'_>,
415    ) -> bool {
416        let barrel_idx = entry.barrel.0 as usize;
417        let source_idx = entry.source.0 as usize;
418
419        if barrel_idx >= modules.len() || source_idx >= modules.len() {
420            return false;
421        }
422
423        if entry.exported_name == "*" {
424            propagate_star_re_export(StarReExportPropagation {
425                modules,
426                edges,
427                edges_by_target: context.edges_by_target,
428                binding_usage: context.binding_usage,
429                effective_exports: context.effective_exports,
430                barrel_id: entry.barrel,
431                barrel_idx,
432                source_id: entry.source,
433                source_idx,
434                entry_star_targets: context.entry_star_targets,
435                triggering_is_type_only: entry.is_type_only,
436                synthetic_stubs: context.synthetic_stubs,
437                reference_paths: context.reference_paths,
438            })
439        } else {
440            propagate_named_re_export(NamedReExportPropagation {
441                modules,
442                effective_exports: context.effective_exports,
443                barrel_id: entry.barrel,
444                barrel_idx,
445                source_id: entry.source,
446                source_idx,
447                imported_name: &entry.imported_name,
448                exported_name: &entry.exported_name,
449                is_type_only: entry.is_type_only,
450                existing_refs: context.existing_refs,
451                declaration_routes: context.declaration_routes,
452                scratch: context.scratch,
453                reference_paths: context.reference_paths,
454            })
455        }
456    }
457
458    #[cfg(test)]
459    fn run_re_export_full_scan(input: LegacyReExportFullScan<'_>) {
460        let LegacyReExportFullScan {
461            modules,
462            edges,
463            re_export_info,
464            entry_star_targets,
465            edges_by_target,
466            module_by_id,
467            effective_exports,
468            reference_paths,
469        } = input;
470        let max_iterations = re_export_info.len().saturating_add(1);
471        let mut existing_refs: FxHashSet<RoutedReferenceKey> = FxHashSet::default();
472        let mut synthetic_stubs: FxHashSet<(FileId, String, bool)> = FxHashSet::default();
473        let binding_usage = ImportBindingUsageIndex::build(module_by_id);
474        let mut declaration_routes = EffectiveDeclarationRouteCache::default();
475        let mut scratch = NamedPropagationScratch::default();
476
477        for _ in 0..max_iterations {
478            let mut changed = false;
479            for entry in re_export_info {
480                let mut context = ReExportContext {
481                    entry_star_targets,
482                    edges_by_target,
483                    binding_usage: &binding_usage,
484                    effective_exports,
485                    existing_refs: &mut existing_refs,
486                    synthetic_stubs: &mut synthetic_stubs,
487                    declaration_routes: &mut declaration_routes,
488                    scratch: &mut scratch,
489                    reference_paths,
490                };
491                changed |= Self::propagate_re_export_entry(modules, edges, entry, &mut context);
492            }
493            if !changed {
494                break;
495            }
496        }
497    }
498}
499
500/// Find SCCs of size >= 2 in the re-export subgraph and self-re-export
501/// edges, emit one `tracing::warn!` per cycle, AND return structured cycle
502/// data for the user-visible `re-export-cycle` finding type.
503///
504/// The `tracing::warn!` emissions remain unchanged from #442 (RUST_LOG=warn
505/// operators still see them). The returned `Vec<GraphReExportCycle>` is the
506/// structured surface that the analysis backend consumes and wraps in typed
507/// `ReExportCycleFinding`s for end-user output. See issue #515.
508fn find_re_export_cycles(
509    modules: &[super::types::ModuleNode],
510    re_export_info: &[ReExportTuple],
511) -> Vec<GraphReExportCycle> {
512    let mut cycles: Vec<GraphReExportCycle> = Vec::new();
513
514    let (node_index, nodes) = build_re_export_node_index(re_export_info);
515    let n = nodes.len();
516    if n == 0 {
517        return cycles;
518    }
519
520    let adj = build_re_export_adjacency(re_export_info, &node_index, modules, &mut cycles);
521
522    let sccs = tarjan_scc(n, &adj);
523
524    for scc in &sccs {
525        if scc.len() < 2 {
526            continue;
527        }
528        cycles.push(build_multi_node_cycle(scc, &nodes, modules));
529    }
530
531    cycles
532}
533
534/// Assign a dense node index to every distinct barrel / source file id.
535fn build_re_export_node_index(
536    re_export_info: &[ReExportTuple],
537) -> (FxHashMap<FileId, usize>, Vec<FileId>) {
538    let mut node_index: FxHashMap<FileId, usize> = FxHashMap::default();
539    let mut nodes: Vec<FileId> = Vec::new();
540    for entry in re_export_info {
541        for &id in &[entry.barrel, entry.source] {
542            node_index.entry(id).or_insert_with(|| {
543                let idx = nodes.len();
544                nodes.push(id);
545                idx
546            });
547        }
548    }
549    (node_index, nodes)
550}
551
552/// Build the adjacency list for the re-export subgraph, emitting a self-loop
553/// `GraphReExportCycle` for any barrel that re-exports from itself.
554fn build_re_export_adjacency(
555    re_export_info: &[ReExportTuple],
556    node_index: &FxHashMap<FileId, usize>,
557    modules: &[super::types::ModuleNode],
558    cycles: &mut Vec<GraphReExportCycle>,
559) -> Vec<Vec<usize>> {
560    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); node_index.len()];
561    let mut seen_edge: FxHashSet<(usize, usize)> = FxHashSet::default();
562    let mut seen_self_loop: FxHashSet<FileId> = FxHashSet::default();
563    for entry in re_export_info {
564        let from = node_index[&entry.barrel];
565        let to = node_index[&entry.source];
566        if from == to {
567            if seen_self_loop.insert(entry.barrel) {
568                cycles.push(build_self_loop_cycle(entry.barrel, modules));
569            }
570            continue;
571        }
572        if seen_edge.insert((from, to)) {
573            adj[from].push(to);
574        }
575    }
576    adj
577}
578
579/// Emit the `tracing::warn!` and structured cycle for a self-re-export edge.
580fn build_self_loop_cycle(
581    barrel: FileId,
582    modules: &[super::types::ModuleNode],
583) -> GraphReExportCycle {
584    let (path_buf, path_display) = module_path_and_display(barrel, modules);
585    tracing::warn!(
586        file = path_display.as_str(),
587        "Re-export self-loop detected: this file re-exports from \
588         itself. Chain propagation is structurally a no-op for \
589         these edges. Inspect the barrel for an accidental \
590         `export * from './<this-file>'` after a rename or move."
591    );
592    GraphReExportCycle {
593        files: vec![path_buf],
594        file_ids: vec![barrel],
595        is_self_loop: true,
596    }
597}
598
599/// Emit the `tracing::warn!` and structured cycle for a multi-node SCC.
600fn build_multi_node_cycle(
601    scc: &[usize],
602    nodes: &[FileId],
603    modules: &[super::types::ModuleNode],
604) -> GraphReExportCycle {
605    let mut triples: Vec<(PathBuf, String, FileId)> = scc
606        .iter()
607        .map(|&idx| {
608            let file_id = nodes[idx];
609            let (path, display) = module_path_and_display(file_id, modules);
610            (path, display, file_id)
611        })
612        .collect();
613    triples.sort_by(|a, b| a.1.cmp(&b.1));
614    let members = triples
615        .iter()
616        .map(|(_, d, _)| d.as_str())
617        .collect::<Vec<_>>()
618        .join(" <-> ");
619    tracing::warn!(
620        cycle_size = scc.len(),
621        members = members.as_str(),
622        "Re-export cycle detected: chain propagation may be incomplete \
623         for symbols on this barrel loop. Break the cycle to restore \
624         full reachability analysis."
625    );
626    let (files, file_ids) = triples.into_iter().fold(
627        (Vec::new(), Vec::new()),
628        |(mut paths, mut ids), (p, _, id)| {
629            paths.push(p);
630            ids.push(id);
631            (paths, ids)
632        },
633    );
634    GraphReExportCycle {
635        files,
636        file_ids,
637        is_self_loop: false,
638    }
639}
640
641/// Resolve a `FileId` to its `(PathBuf, display string)`, falling back to a
642/// placeholder when the id is outside the module list.
643fn module_path_and_display(
644    file_id: FileId,
645    modules: &[super::types::ModuleNode],
646) -> (PathBuf, String) {
647    let i = file_id.0 as usize;
648    if i < modules.len() {
649        let p = modules[i].path.clone();
650        let d = p.display().to_string();
651        (p, d)
652    } else {
653        let placeholder = format!("<file id {i}>");
654        (PathBuf::from(&placeholder), placeholder)
655    }
656}
657
658struct TarjanFrame {
659    node: usize,
660    next_succ: usize,
661}
662
663/// Mutable Tarjan SCC state shared across the iterative DFS.
664struct TarjanState {
665    index_counter: u32,
666    indices: Vec<u32>,
667    lowlinks: Vec<u32>,
668    on_stack: fixedbitset::FixedBitSet,
669    stack: Vec<usize>,
670    sccs: Vec<Vec<usize>>,
671}
672
673impl TarjanState {
674    fn new(n: usize) -> Self {
675        Self {
676            index_counter: 0,
677            indices: vec![u32::MAX; n],
678            lowlinks: vec![0; n],
679            on_stack: fixedbitset::FixedBitSet::with_capacity(n),
680            stack: Vec::new(),
681            sccs: Vec::new(),
682        }
683    }
684
685    /// Assign the next DFS index to `node` and push it onto the SCC stack.
686    fn discover(&mut self, node: usize) {
687        self.indices[node] = self.index_counter;
688        self.lowlinks[node] = self.index_counter;
689        self.index_counter = self.index_counter.saturating_add(1);
690        self.stack.push(node);
691        self.on_stack.insert(node);
692    }
693
694    /// Advance one successor of the current frame, pushing a child frame when a
695    /// new node is discovered. Returns the child node to descend into, if any.
696    fn step_successor(&mut self, frame: &mut TarjanFrame, adj: &[Vec<usize>]) -> Option<usize> {
697        let v = frame.node;
698        let w = adj[v][frame.next_succ];
699        frame.next_succ = frame.next_succ.saturating_add(1);
700        if self.indices[w] == u32::MAX {
701            self.discover(w);
702            Some(w)
703        } else {
704            if self.on_stack.contains(w) {
705                self.lowlinks[v] = self.lowlinks[v].min(self.indices[w]);
706            }
707            None
708        }
709    }
710
711    /// Finish the current frame: emit its SCC if it is a root, then propagate
712    /// its lowlink to the parent frame.
713    fn finish_frame(&mut self, v: usize, parent: Option<usize>) {
714        if self.lowlinks[v] == self.indices[v] {
715            let mut scc = Vec::new();
716            while let Some(w) = self.stack.pop() {
717                self.on_stack.remove(w);
718                scc.push(w);
719                if w == v {
720                    break;
721                }
722            }
723            self.sccs.push(scc);
724        }
725        if let Some(pv) = parent {
726            self.lowlinks[pv] = self.lowlinks[pv].min(self.lowlinks[v]);
727        }
728    }
729}
730
731/// Iterative Tarjan's strongly connected components, returns SCCs that
732/// contain at least one node. The graph is given as adjacency-by-index;
733/// the caller maps node indices back to FileIds.
734fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
735    let mut state = TarjanState::new(n);
736
737    for start in 0..n {
738        if state.indices[start] != u32::MAX {
739            continue;
740        }
741        state.discover(start);
742        let mut dfs: Vec<TarjanFrame> = vec![TarjanFrame {
743            node: start,
744            next_succ: 0,
745        }];
746
747        while let Some(frame) = dfs.last_mut() {
748            let v = frame.node;
749            if frame.next_succ < adj[v].len() {
750                if let Some(child) = state.step_successor(frame, adj) {
751                    dfs.push(TarjanFrame {
752                        node: child,
753                        next_succ: 0,
754                    });
755                }
756            } else {
757                dfs.pop();
758                state.finish_frame(v, dfs.last().map(|parent| parent.node));
759            }
760        }
761    }
762
763    state.sccs
764}