Skip to main content

brokk_bifrost_core/analyzer/
capabilities.rs

1use crate::analyzer::code_unit_index::CodeUnitIndex;
2use crate::analyzer::model::{CodeUnit, ImportInfo, ProjectFile};
3use crate::analyzer::pool_memo::{KeyedPoolSafeMemo, PoolSafeMemo};
4use crate::analyzer::query_token::QueryToken;
5use crate::cancellation::CancellationToken;
6use crate::compact_graph::{CompactRows, CompactRowsBuilder};
7use crate::hash::{HashMap, HashSet};
8use std::any::Any;
9use std::collections::{BTreeSet, VecDeque};
10use std::sync::Arc;
11
12use rayon::prelude::*;
13
14pub trait CapabilityProvider: Any {
15    fn as_any(&self) -> &dyn Any;
16}
17
18impl<T: Any> CapabilityProvider for T {
19    fn as_any(&self) -> &dyn Any {
20        self
21    }
22}
23
24pub trait ImportAnalysisProvider: CapabilityProvider + Send + Sync {
25    /// Shared, not owned: every memoizing implementation already stores the set
26    /// behind an `Arc` in its per-file cache, and the hottest consumers only
27    /// read it (a membership test in candidate discovery, a projection to
28    /// source files in the reverse-import index). Returning the `Arc` removes a
29    /// whole-set clone per cache hit and per insert.
30    fn imported_code_units_of(&self, file: &ProjectFile) -> Arc<HashSet<CodeUnit>>;
31    fn referencing_files_of(&self, file: &ProjectFile) -> HashSet<ProjectFile>;
32
33    /// Return the union of files that reference any `target`, restricted to
34    /// the caller's candidate universe.
35    ///
36    /// A depth-bounded caller already knows both sets and must not be forced to
37    /// initialize a provider's whole-workspace reverse index once per target.
38    /// Providers with a structured batch representation can override this.
39    /// The default preserves the file-at-a-time result for every existing
40    /// provider.
41    fn referencing_files_of_targets(
42        &self,
43        targets: &HashSet<ProjectFile>,
44        candidates: &[ProjectFile],
45        cancellation: &CancellationToken,
46    ) -> HashSet<ProjectFile> {
47        let candidate_set: HashSet<ProjectFile> = candidates.iter().cloned().collect();
48        let mut referencing = HashSet::default();
49        for target in targets {
50            if cancellation.is_cancelled() {
51                break;
52            }
53            referencing.extend(
54                self.referencing_files_of(target)
55                    .into_iter()
56                    .filter(|file| candidate_set.contains(file)),
57            );
58        }
59        referencing
60    }
61
62    /// Return import facts for a group of files without requiring each caller
63    /// to hydrate a complete per-file analyzer state. `None` preserves the
64    /// existing file-at-a-time behavior for providers without a bulk read model.
65    fn import_infos_for_files(
66        &self,
67        _files: &[ProjectFile],
68    ) -> Option<HashMap<ProjectFile, Vec<ImportInfo>>> {
69        None
70    }
71
72    /// The file's import facts, which fall through to the import-tier store
73    /// read when nothing retained answers. Crossing that tier is only cheap
74    /// under an open request scope, so the caller proves one is open by
75    /// passing a [`QueryToken`] (issue #2423).
76    fn import_info_of(&self, _token: QueryToken<'_>, _file: &ProjectFile) -> Vec<ImportInfo> {
77        Vec::new()
78    }
79
80    /// Resolve imported source units from already-loaded import facts. Providers
81    /// that cannot do this cheaply return `None` and use `imported_code_units_of`.
82    fn imported_code_units_from_infos(
83        &self,
84        _file: &ProjectFile,
85        _imports: &[ImportInfo],
86    ) -> Option<Arc<HashSet<CodeUnit>>> {
87        None
88    }
89
90    /// Resolve directly imported project files from already-loaded import facts.
91    /// Providers that do not expose file-level edges return `None` and callers
92    /// can derive a conservative approximation from imported declarations.
93    fn imported_files_from_infos(
94        &self,
95        _file: &ProjectFile,
96        _imports: &[ImportInfo],
97    ) -> Option<HashSet<ProjectFile>> {
98        None
99    }
100
101    fn relevant_imports_for(&self, _code_unit: &CodeUnit) -> HashSet<String> {
102        HashSet::default()
103    }
104
105    /// Whether `source_file` can reference a declaration of `target`. A `true`
106    /// is an answer; a `false` is only an absence of evidence, which is why
107    /// callers still run the expansion backstop after one. Providers that can
108    /// prove the negative override [`Self::import_reachability`] instead and
109    /// derive this from it.
110    fn could_import_file(
111        &self,
112        _source_file: &ProjectFile,
113        _imports: &[ImportInfo],
114        _target: &ProjectFile,
115    ) -> bool {
116        false
117    }
118
119    /// Resolve, in one batch, whatever [`Self::could_import_file`] would look
120    /// up once per candidate.
121    ///
122    /// The shared import-graph candidate walk visits every workspace file, so
123    /// a provider that answers each visit with its own store lookup pays that
124    /// lookup once per import statement in the workspace -- 397k to 662k
125    /// `definition_candidates` round trips inside a single `scan_usages`
126    /// query on a 35k-file Rust workspace (#1748). The walk knows its whole
127    /// candidate set before it inspects any of it, so this hook lets the
128    /// provider enumerate its keys up front and collapse them into batched
129    /// reads.
130    ///
131    /// Doing nothing is always correct: the per-candidate path still answers
132    /// exactly as before, just without the shared warm result. Providers whose
133    /// per-candidate answer is already file-local (Python, JS/TS, Go, C++)
134    /// keep the default.
135    fn prefetch_import_targets(
136        &self,
137        _files: &[ProjectFile],
138        _import_infos: Option<&HashMap<ProjectFile, Vec<ImportInfo>>>,
139        _cancellation: &crate::cancellation::CancellationToken,
140    ) {
141    }
142
143    /// The three-valued form of [`Self::could_import_file`], which lets a
144    /// provider that has a completeness proof retire the caller's backstop.
145    ///
146    /// The default preserves the historical contract exactly by mapping the
147    /// bool spelling's `true` to [`ImportReachability::Reaches`] and its
148    /// `false` to [`ImportReachability::Unknown`] -- never to `DoesNotReach`,
149    /// because the bool contract never distinguished "no" from "I did not
150    /// find one".
151    ///
152    /// Bridging runs in this direction only. A provider overrides this method
153    /// and defines `could_import_file` over it, so the two spellings cannot
154    /// drift apart.
155    fn import_reachability(
156        &self,
157        source_file: &ProjectFile,
158        imports: &[ImportInfo],
159        target: &ProjectFile,
160    ) -> ImportReachability {
161        if self.could_import_file(source_file, imports, target) {
162            ImportReachability::Reaches
163        } else {
164            ImportReachability::Unknown
165        }
166    }
167}
168
169/// How completely a provider can answer "can `source_file` reference a
170/// declaration of `target`?".
171///
172/// Candidate discovery asks this per (candidate, target) pair and, when it
173/// gets no positive answer, materializes every declaration the candidate
174/// imports as a recall backstop. For a namespace-import language that
175/// expansion is the whole workspace's top-level types per using directive,
176/// which is the shape that burned #1194. The backstop exists only because the
177/// bool contract could not distinguish a proven "no" from an unproven one.
178///
179/// A provider must return `DoesNotReach` only from a proof that covers every
180/// way its language can name a declaration without importing it. Anything less
181/// is `Unknown`: the cost of an unnecessary expansion is time, the cost of a
182/// wrong `DoesNotReach` is a missing usage.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum ImportReachability {
185    /// The candidate can reference the target. Accept it without expanding.
186    Reaches,
187    /// The candidate provably cannot. Reject it without expanding.
188    DoesNotReach,
189    /// Undecided. The caller runs its own backstop, exactly as before this
190    /// verdict existed.
191    Unknown,
192}
193
194/// Resolve direct project-file edges from structured import facts. Prefer a
195/// provider's file-level resolver so imports whose target has no declarations
196/// remain visible; otherwise conservatively project resolved declaration
197/// identities back to their source files.
198pub fn resolve_imported_files_from_infos(
199    provider: &dyn ImportAnalysisProvider,
200    file: &ProjectFile,
201    imports: &[ImportInfo],
202) -> HashSet<ProjectFile> {
203    provider
204        .imported_files_from_infos(file, imports)
205        .unwrap_or_else(|| {
206            provider
207                .imported_code_units_from_infos(file, imports)
208                .unwrap_or_else(|| provider.imported_code_units_of(file))
209                .iter()
210                .map(|unit| unit.source().clone())
211                .collect()
212        })
213}
214
215pub fn build_reverse_import_index<F>(
216    files: &[ProjectFile],
217    resolve_imported: F,
218    parallel: bool,
219) -> HashMap<ProjectFile, Arc<HashSet<ProjectFile>>>
220where
221    F: Fn(&ProjectFile) -> Arc<HashSet<CodeUnit>> + Sync,
222{
223    build_reverse_file_index(
224        files,
225        |file| {
226            resolve_imported(file)
227                .iter()
228                .map(|code_unit| code_unit.source().clone())
229                .collect::<Vec<_>>()
230        },
231        parallel,
232    )
233}
234
235pub type ReverseFileIndex = HashMap<ProjectFile, Arc<HashSet<ProjectFile>>>;
236
237pub fn memoized_reverse_import_index<F, Files>(
238    memo: &PoolSafeMemo<ReverseFileIndex>,
239    files: Files,
240    resolve_imported: F,
241) -> Arc<ReverseFileIndex>
242where
243    F: Fn(&ProjectFile) -> Arc<HashSet<CodeUnit>> + Sync + Copy,
244    Files: Fn() -> Vec<ProjectFile> + Copy,
245{
246    memoized_reverse_file_index(memo, files, |file| {
247        resolve_imported(file)
248            .iter()
249            .map(|code_unit| code_unit.source().clone())
250            .collect::<Vec<_>>()
251    })
252}
253
254pub fn memoized_reverse_file_index<F, I, Files>(
255    memo: &PoolSafeMemo<ReverseFileIndex>,
256    files: Files,
257    resolve_targets: F,
258) -> Arc<ReverseFileIndex>
259where
260    F: Fn(&ProjectFile) -> I + Sync + Copy,
261    I: IntoIterator<Item = ProjectFile>,
262    Files: Fn() -> Vec<ProjectFile> + Copy,
263{
264    memo.get_or_build(
265        || {
266            let files = files();
267            build_reverse_file_index(&files, resolve_targets, true)
268        },
269        || {
270            let files = files();
271            build_reverse_file_index(&files, resolve_targets, false)
272        },
273    )
274}
275
276pub fn build_reverse_file_index<F, I>(
277    files: &[ProjectFile],
278    resolve_targets: F,
279    parallel: bool,
280) -> HashMap<ProjectFile, Arc<HashSet<ProjectFile>>>
281where
282    F: Fn(&ProjectFile) -> I + Sync,
283    I: IntoIterator<Item = ProjectFile>,
284{
285    let collect_edges = |file: &ProjectFile| {
286        let source = file.clone();
287        resolve_targets(file)
288            .into_iter()
289            .filter_map(move |target| (target != source).then(|| (target, source.clone())))
290            .collect::<Vec<_>>()
291    };
292    let edges: Vec<_> = if parallel {
293        files.par_iter().flat_map(collect_edges).collect()
294    } else {
295        files.iter().flat_map(collect_edges).collect()
296    };
297
298    let mut reverse: HashMap<ProjectFile, HashSet<ProjectFile>> = HashMap::default();
299    for (target, source) in edges {
300        reverse.entry(target).or_default().insert(source);
301    }
302    reverse
303        .into_iter()
304        .map(|(file, refs)| (file, Arc::new(refs)))
305        .collect()
306}
307
308/// `Send + Sync` like its three sibling capabilities: every implementor is an
309/// analyzer, and a parallel whole-workspace scan holds the provider across its
310/// fan-out (`kotlin_graph`'s edge builder does exactly that).
311pub trait TypeAliasProvider: CapabilityProvider + Send + Sync {
312    fn is_type_alias(&self, _code_unit: &CodeUnit) -> bool {
313        false
314    }
315}
316
317pub trait TestDetectionProvider: CapabilityProvider {}
318
319/// Which slice of the workspace a descendant index covers.
320///
321/// This is the memo key, and two values is the whole range: the excluded set is
322/// a pure function of the analyzer and the file, so every request that asks to
323/// leave test files out describes the same index.
324#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
325pub enum DescendantIndexVariant {
326    /// Every class-like declaration the analyzer indexes.
327    WholeWorkspace,
328    /// The declarations whose source file the caller's predicate rejected are
329    /// left out. In practice that is a `scan_usages` request with
330    /// `include_tests: false`, whose test files the answer would discard anyway.
331    ProductionOnly,
332}
333
334/// The request state a whole-workspace descendant-index build needs: the
335/// deadline it polls, and the slice of the workspace it covers.
336///
337/// The two travel together because one loop consumes both -- the per-class loop
338/// in [`build_direct_descendant_index_from_candidates`] and, for C++, the
339/// per-file include-closure walk beneath it -- and because the slice is the
340/// memo key that the deadline's complete-or-nothing rule is applied to.
341///
342/// The exclusion predicate is supplied by the caller rather than named here.
343/// Test classification needs an analyzer (`file_is_test_only` reads a language
344/// module index), which this crate must not know about; the closure crossing is
345/// the same one the Rust usage walks already use for their `keep_going`
346/// predicate. **The predicate must be a pure function of the analyzer and the
347/// file.** That is the condition that makes [`DescendantIndexVariant`] a
348/// complete key: two requests that both exclude test files must describe the
349/// same index, or one would be served the other's.
350#[derive(Clone, Copy)]
351pub struct DescendantIndexScope<'a> {
352    cancellation: &'a CancellationToken,
353    excluded_source: Option<&'a dyn Fn(&ProjectFile) -> bool>,
354}
355
356impl<'a> DescendantIndexScope<'a> {
357    /// Every class in the workspace, stopping when `cancellation` says to.
358    pub fn whole_workspace(cancellation: &'a CancellationToken) -> Self {
359        Self {
360            cancellation,
361            excluded_source: None,
362        }
363    }
364
365    /// Every class whose source file `excluded` rejects is left out of the
366    /// index entirely -- it is never handed to `get_direct_ancestors`, so the
367    /// per-file resolution work behind that call is never charged.
368    pub fn excluding_sources(
369        cancellation: &'a CancellationToken,
370        excluded: &'a dyn Fn(&ProjectFile) -> bool,
371    ) -> Self {
372        Self {
373            cancellation,
374            excluded_source: Some(excluded),
375        }
376    }
377
378    pub fn cancellation(&self) -> &'a CancellationToken {
379        self.cancellation
380    }
381
382    pub fn variant(&self) -> DescendantIndexVariant {
383        match self.excluded_source {
384            Some(_) => DescendantIndexVariant::ProductionOnly,
385            None => DescendantIndexVariant::WholeWorkspace,
386        }
387    }
388
389    /// The poll predicate for a builder that would rather take a plain
390    /// `Fn() -> bool` than a token.
391    pub fn keep_going(&self) -> impl Fn() -> bool + use<'_> {
392        || !self.cancellation.is_cancelled()
393    }
394
395    /// Whether `declaration` belongs in this index.
396    pub fn admits(&self, declaration: &CodeUnit) -> bool {
397        self.excluded_source
398            .is_none_or(|excluded| !excluded(declaration.source()))
399    }
400}
401
402pub trait TypeHierarchyProvider: CapabilityProvider + Send + Sync {
403    fn get_direct_ancestors(&self, code_unit: &CodeUnit) -> Vec<CodeUnit>;
404    fn get_direct_descendants(&self, code_unit: &CodeUnit) -> HashSet<CodeUnit>;
405
406    fn supports_type_hierarchy(&self, code_unit: &CodeUnit) -> bool {
407        code_unit.is_class()
408    }
409
410    fn get_ancestors(&self, code_unit: &CodeUnit) -> Vec<CodeUnit> {
411        traverse_hierarchy(code_unit, |next| self.get_direct_ancestors(next))
412    }
413
414    fn get_descendants(&self, code_unit: &CodeUnit) -> Vec<CodeUnit> {
415        traverse_hierarchy(code_unit, |next| {
416            self.get_direct_descendants(next).into_iter().collect()
417        })
418    }
419
420    /// [`Self::get_direct_ancestors`] under a caller's deadline.
421    ///
422    /// `None` means the resolution stopped short; it is never an empty answer,
423    /// which stays the honest way to say "no resolvable base types". The
424    /// default checks the deadline once and delegates, which is right for a
425    /// provider whose ancestor resolution is a lookup. C++ overrides it because
426    /// resolving one class's bases means walking that file's whole transitive
427    /// `#include` closure.
428    fn get_direct_ancestors_within(
429        &self,
430        code_unit: &CodeUnit,
431        scope: &DescendantIndexScope<'_>,
432    ) -> Option<Vec<CodeUnit>> {
433        (!scope.cancellation().is_cancelled()).then(|| self.get_direct_ancestors(code_unit))
434    }
435
436    /// [`Self::get_direct_descendants`] under a caller's deadline and workspace
437    /// slice.
438    ///
439    /// `None` means the answer is not available because the build stopped
440    /// short. It is never a partial answer, and a stopped build publishes
441    /// nothing: a truncated index memoized as complete would be served to every
442    /// later caller as the truth (the rule
443    /// `cancelled_cold_candidate_discovery_does_not_publish_partial_index`
444    /// pins for the Rust walk caches).
445    ///
446    /// The default checks the deadline once and delegates, which is the correct
447    /// behaviour for a provider whose descendant answer is bounded by
448    /// construction. Every provider that inverts the ancestor relation over the
449    /// whole workspace overrides this; a provider that also honours
450    /// [`DescendantIndexScope::admits`] builds one index per
451    /// [`DescendantIndexVariant`]. Ignoring `admits` is sound -- the index is
452    /// then a superset and the caller's own file filter still applies -- so
453    /// only the providers where the prune pays for itself implement it.
454    fn get_direct_descendants_within(
455        &self,
456        code_unit: &CodeUnit,
457        scope: &DescendantIndexScope<'_>,
458    ) -> Option<HashSet<CodeUnit>> {
459        (!scope.cancellation().is_cancelled()).then(|| self.get_direct_descendants(code_unit))
460    }
461
462    /// [`Self::get_descendants`] under a caller's deadline and workspace slice,
463    /// polling once per node of the walk. `None` carries the same meaning as in
464    /// [`Self::get_direct_descendants_within`].
465    fn get_descendants_within(
466        &self,
467        code_unit: &CodeUnit,
468        scope: &DescendantIndexScope<'_>,
469    ) -> Option<Vec<CodeUnit>> {
470        traverse_hierarchy_while(code_unit, scope.cancellation(), |next| {
471            self.get_direct_descendants_within(next, scope)
472                .map(|descendants| descendants.into_iter().collect())
473        })
474    }
475
476    fn get_polymorphic_matches<T: CodeUnitIndex>(
477        &self,
478        target: &CodeUnit,
479        analyzer: &T,
480    ) -> Vec<CodeUnit>
481    where
482        Self: Sized,
483    {
484        if !target.is_function() {
485            return Vec::new();
486        }
487
488        let Some(parent) = analyzer.parent_of(target) else {
489            return Vec::new();
490        };
491
492        self.get_descendants(&parent)
493    }
494}
495
496/// Exact declaration identities plus compact ancestor-to-descendant rows.
497pub struct DirectDescendantIndex {
498    nodes: Box<[CodeUnit]>,
499    row_by_ancestor: HashMap<CodeUnit, u32>,
500    descendants: CompactRows<u32>,
501}
502
503impl DirectDescendantIndex {
504    pub fn from_indexed_nodes(
505        nodes: Vec<CodeUnit>,
506        index_by_node: HashMap<CodeUnit, u32>,
507        mut edges: Vec<(u32, u32)>,
508    ) -> Self {
509        assert_eq!(nodes.len(), index_by_node.len());
510        assert!(nodes.iter().enumerate().all(|(index, node)| {
511            index_by_node.get(node).copied()
512                == Some(
513                    u32::try_from(index).expect("hierarchy index declarations must fit in a u32"),
514                )
515        }));
516        assert!(edges.iter().all(|(ancestor, descendant)| {
517            (*ancestor as usize) < nodes.len() && (*descendant as usize) < nodes.len()
518        }));
519        edges.sort_unstable();
520        edges.dedup();
521
522        let row_count = usize::from(!edges.is_empty())
523            + edges
524                .windows(2)
525                .filter(|pair| pair[0].0 != pair[1].0)
526                .count();
527        let mut row_by_ancestor = HashMap::default();
528        let mut descendants = CompactRowsBuilder::with_capacity(row_count, edges.len());
529        let mut cursor = 0;
530        while cursor < edges.len() {
531            let ancestor = edges[cursor].0;
532            let start = cursor;
533            while cursor < edges.len() && edges[cursor].0 == ancestor {
534                cursor += 1;
535            }
536            let row =
537                u32::try_from(descendants.rows()).expect("hierarchy index rows must fit in a u32");
538            row_by_ancestor.insert(nodes[ancestor as usize].clone(), row);
539            descendants.push_row(
540                edges[start..cursor]
541                    .iter()
542                    .map(|(_, descendant)| *descendant),
543            );
544        }
545        Self {
546            nodes: nodes.into_boxed_slice(),
547            row_by_ancestor,
548            descendants: descendants.finish(),
549        }
550    }
551
552    pub fn descendants(&self, ancestor: &CodeUnit) -> HashSet<CodeUnit> {
553        let Some(row) = self.row_by_ancestor.get(ancestor).copied() else {
554            return HashSet::default();
555        };
556        self.descendants
557            .row(row as usize)
558            .iter()
559            .map(|descendant| self.nodes[*descendant as usize].clone())
560            .collect()
561    }
562}
563
564/// Answer a descendant query from a variant-keyed index cell, building the
565/// variant the caller asked for if it is missing.
566///
567/// Every analyzer that memoizes a whole-workspace descendant index repeats this
568/// same dance: pick the cell for the scope's variant, build while the deadline
569/// holds, publish nothing if it does not, project the ancestor's row. It lives
570/// here so the complete-or-nothing rule is stated once instead of copied into
571/// each language module.
572pub fn descendants_from_variant_index(
573    index: &KeyedPoolSafeMemo<DescendantIndexVariant, DirectDescendantIndex>,
574    scope: &DescendantIndexScope<'_>,
575    code_unit: &CodeUnit,
576    build: impl Fn() -> Option<DirectDescendantIndex>,
577) -> Option<HashSet<CodeUnit>> {
578    Some(
579        index
580            .cell(&scope.variant())
581            // The builders are serial, so the same closure serves both memo
582            // arms; the memo's value here is the non-blocking claim protocol.
583            .get_or_build_while(&scope.keep_going(), &build, &build)?
584            .descendants(code_unit),
585    )
586}
587
588/// Invert the ancestor relation over every class the analyzer indexes.
589///
590/// `scope` decides two things. Its predicate drops declarations before they are
591/// ever handed to `get_direct_ancestors`, which is where the per-declaration
592/// resolution cost is charged (for C++ that is a whole transitive `#include`
593/// closure per declaring file). Its token bounds the loop: `None` means the
594/// build stopped short and must not be published.
595pub fn build_direct_descendant_index<A, P>(
596    analyzer: &A,
597    provider: &P,
598    scope: &DescendantIndexScope<'_>,
599) -> Option<DirectDescendantIndex>
600where
601    A: CodeUnitIndex,
602    P: TypeHierarchyProvider + ?Sized,
603{
604    build_direct_descendant_index_from_candidates(
605        analyzer
606            .all_declarations()
607            .filter(|candidate| candidate.is_class() && scope.admits(candidate))
608            .collect(),
609        |candidate| provider.get_direct_ancestors_within(candidate, scope),
610        &scope.keep_going(),
611    )
612}
613
614/// The edge-building half of [`build_direct_descendant_index`], for providers
615/// that assemble their candidate list some other way.
616///
617/// `keep_going` is polled once per candidate. That granularity is the natural
618/// checkpoint: one candidate is one `direct_ancestors` call, which is the unit
619/// of work whose tens of thousands of repetitions made this loop unbounded in
620/// the first place (issue #1748). `direct_ancestors` answers `None` when its
621/// own work stopped short, which stops this loop too -- a candidate whose bases
622/// were half-resolved must not contribute a half-populated edge set.
623pub fn build_direct_descendant_index_from_candidates<F>(
624    mut candidates: Vec<CodeUnit>,
625    mut direct_ancestors: F,
626    keep_going: &dyn Fn() -> bool,
627) -> Option<DirectDescendantIndex>
628where
629    F: FnMut(&CodeUnit) -> Option<Vec<CodeUnit>>,
630{
631    candidates.sort();
632    candidates.dedup();
633    let mut types_by_fq_name: HashMap<String, Vec<CodeUnit>> = HashMap::default();
634    for candidate in &candidates {
635        types_by_fq_name
636            .entry(candidate.fq_name())
637            .or_default()
638            .push(candidate.clone());
639    }
640    let mut nodes = candidates.clone();
641    let mut index_by_node: HashMap<_, _> = nodes
642        .iter()
643        .enumerate()
644        .map(|(index, node)| {
645            (
646                node.clone(),
647                u32::try_from(index).expect("hierarchy index declarations must fit in a u32"),
648            )
649        })
650        .collect();
651    let mut edges = Vec::new();
652    for candidate in candidates {
653        if !keep_going() {
654            return None;
655        }
656        let descendant = index_by_node[&candidate];
657        for ancestor in direct_ancestors(&candidate)? {
658            let ancestor = types_by_fq_name
659                .get(&ancestor.fq_name())
660                .and_then(|same_name| {
661                    let mut same_source = same_name
662                        .iter()
663                        .filter(|unit| unit.source() == candidate.source());
664                    let exact = same_source.next()?;
665                    same_source.next().is_none().then(|| exact.clone())
666                })
667                .unwrap_or(ancestor);
668            let ancestor = *index_by_node.entry(ancestor.clone()).or_insert_with(|| {
669                let index = u32::try_from(nodes.len())
670                    .expect("hierarchy index declarations must fit in a u32");
671                nodes.push(ancestor);
672                index
673            });
674            edges.push((ancestor, descendant));
675        }
676    }
677    Some(DirectDescendantIndex::from_indexed_nodes(
678        nodes,
679        index_by_node,
680        edges,
681    ))
682}
683
684/// [`traverse_hierarchy`] under a deadline: polls once per node popped and
685/// propagates a stopped step as `None` rather than returning the nodes it had
686/// already collected. A partial subtype set read as complete would let a caller
687/// conclude that a class has no further subclasses.
688fn traverse_hierarchy_while<F>(
689    root: &CodeUnit,
690    cancellation: &CancellationToken,
691    mut next: F,
692) -> Option<Vec<CodeUnit>>
693where
694    F: FnMut(&CodeUnit) -> Option<Vec<CodeUnit>>,
695{
696    let direct = next(root)?;
697    if direct.is_empty() {
698        return Some(Vec::new());
699    }
700
701    let mut seen = BTreeSet::new();
702    let mut result = Vec::new();
703    let mut queue = VecDeque::new();
704
705    for item in direct {
706        if seen.insert(item.fq_name()) {
707            queue.push_back(item.clone());
708            result.push(item);
709        }
710    }
711
712    while let Some(current) = queue.pop_front() {
713        if cancellation.is_cancelled() {
714            return None;
715        }
716        for item in next(&current)? {
717            if seen.insert(item.fq_name()) {
718                queue.push_back(item.clone());
719                result.push(item);
720            }
721        }
722    }
723
724    Some(result)
725}
726
727fn traverse_hierarchy<F>(root: &CodeUnit, mut next: F) -> Vec<CodeUnit>
728where
729    F: FnMut(&CodeUnit) -> Vec<CodeUnit>,
730{
731    let direct = next(root);
732    if direct.is_empty() {
733        return Vec::new();
734    }
735
736    let mut seen = BTreeSet::new();
737    let mut result = Vec::new();
738    let mut queue = VecDeque::new();
739
740    for item in direct {
741        if seen.insert(item.fq_name()) {
742            queue.push_back(item.clone());
743            result.push(item);
744        }
745    }
746
747    while let Some(current) = queue.pop_front() {
748        for item in next(&current) {
749            if seen.insert(item.fq_name()) {
750                queue.push_back(item.clone());
751                result.push(item);
752            }
753        }
754    }
755
756    result
757}