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