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