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