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