Skip to main content

devscout_rs/query/
index.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use crate::graph;
5use crate::manifest;
6
7use super::seq::{push_ordered_unique, SeqSet};
8
9// ============================================================================
10// GraphIndex -- the built query index.
11// ============================================================================
12
13#[derive(Debug, Clone, Default)]
14/// Represents `InboundEntry`.
15pub struct InboundEntry {
16    /// The inherits value.
17    pub inherits: Vec<usize>,
18    /// The uses type value.
19    pub uses_type: Vec<usize>,
20    /// The uses member value.
21    pub uses_member: Vec<usize>,
22}
23
24/// The value shape of both `heuristic_inbound` and `heuristic_outbound_by_file`
25/// (`inherits`/`uses-type`/`uses-member` -- note no `imports`: an imports edge
26/// names a namespace string, never a def, so no tier can guess one).
27/// Structurally identical to [`InboundEntry`], and deliberately the same type.
28pub type HeuristicEntry = InboundEntry;
29
30#[derive(Debug, Clone, Default)]
31/// Represents `OutboundEntry`.
32pub struct OutboundEntry {
33    /// The inherits value.
34    pub inherits: Vec<usize>,
35    /// The uses type value.
36    pub uses_type: Vec<usize>,
37    /// The uses member value.
38    pub uses_member: Vec<usize>,
39    /// The imports value.
40    pub imports: Vec<usize>,
41}
42
43/// The built query index over a `Graph`. `by_id`/`by_file`/`by_simple_name`/
44/// `by_lower_name` store `graph.defs` indices rather than cloned `Def`s, so a
45/// def is never copied into every bucket it is reachable from.
46pub struct GraphIndex<'g> {
47    /// The graph value.
48    pub graph: &'g graph::Graph,
49    /// The repository root every `file` in the graph is relative to. Held so a
50    /// query can read the one source line a hit sits on; nothing else in this
51    /// module touches the filesystem.
52    pub root: std::path::PathBuf,
53    /// Def id -> index into `graph.defs`.
54    pub by_id: HashMap<String, usize>,
55    /// File -> the def indices declared in it. The `Vec` preserves insertion
56    /// order (defs-array order for a def's own file, then `also_in`
57    /// cross-references in defs-array order) -- `impact_walk`'s next-frontier
58    /// expansion iterates this in order, one of the order-sensitive paths
59    /// documented in the module header.
60    pub by_file: HashMap<String, Vec<usize>>,
61    /// Exact name -> def indices, insertion order = `graph.defs` array order.
62    pub by_simple_name: HashMap<String, Vec<usize>>,
63    /// Lowercased name -> def indices.
64    pub by_lower_name: HashMap<String, Vec<usize>>,
65    /// Def id -> its inbound edges by kind (inherits/uses-type/uses-member).
66    pub inbound: HashMap<String, InboundEntry>,
67    /// File -> its outbound edges by kind (inherits/uses-type/uses-member/imports).
68    pub outbound_by_file: HashMap<String, OutboundEntry>,
69    /// Candidate def id -> the ambiguous edges naming it as a candidate.
70    pub ambiguous_by_candidate: HashMap<String, Vec<usize>>,
71    /// Source file -> the ambiguous edges originating in it.
72    pub ambiguous_by_file: HashMap<String, Vec<usize>>,
73    /// Def id -> its inbound HEURISTIC edges by kind. Heuristic edges live in
74    /// their own adjacency, never mixed into the precise one, so every consumer
75    /// (the impact walk's frontier, the refs tables, PageRank's edge set) reads
76    /// only precise edges unless it asks for guesses by name. Mixing them in
77    /// and filtering later is the shape that eventually leaks a guess into a
78    /// fact -- a filter forgotten in one call site is silent.
79    pub heuristic_inbound: HashMap<String, HeuristicEntry>,
80    /// File -> its outbound HEURISTIC edges (same three-kind shape).
81    pub heuristic_outbound_by_file: HashMap<String, HeuristicEntry>,
82    /// File -> the defs declared in it that carry non-empty `test_methods`. A
83    /// file is a TEST file here because a type declared in it carries an
84    /// attribute a runner discovers, never because of what it is called. Every
85    /// declaring site counts (`def.file` plus each `also_in`), and the per-def
86    /// file dedup keeps two partial blocks in ONE file from listing that def
87    /// twice. Holds `graph.defs` indices, same convention as the buckets above.
88    pub test_defs_by_file: HashMap<String, Vec<usize>>,
89    /// Files present in the graph but absent from the manifest.
90    pub flagged_files: HashSet<String>,
91    /// Whether a manifest was found and parsed.
92    pub manifest_present: bool,
93    /// Implementor def id -> every `ctor-di` edge the resolver confirmed
94    /// resolves TO it. Only an edge carrying a `to` is indexed here --
95    /// ambiguous/infra/unresolved edges never do, so an infra leaf like
96    /// `ILogger<T>` can never enter this map by construction.
97    pub ctor_di_by_to: HashMap<String, Vec<usize>>,
98    /// Injected-type BARE NAME -> the number of DISTINCT CONSTRUCTOR SITES
99    /// (`from_file` + `from_line`) that inject it. A `ctor-param` ref carries
100    /// its constructor's line, so every parameter of one constructor shares one
101    /// site; two classes in one file still count twice. This is the graph's
102    /// proxy for "distinct consuming classes". Every ctor-di edge counts,
103    /// whatever its resolution: how widely a contract is injected is a fact
104    /// about the contract, not about whether an implementor was confirmed.
105    pub ctor_di_fanin: HashMap<String, usize>,
106    /// The repo's `.csproj` project model, rebuilt from `graph.units` --
107    /// `None` when the graph carries none, which is every repo that declares
108    /// no project and every graph written before `units` existed. Owned
109    /// rather than borrowed: `ProjectModel` holds the derived directory map
110    /// and reference closure, neither of which is persisted.
111    pub project: Option<crate::project::ProjectModel>,
112    /// File -> the number of DISTINCT OTHER FILES that reference it through a
113    /// `direct` (inherits/uses-type/uses-member) or heuristic edge. The
114    /// file-level mirror of `ctor_di_fanin`, over the two edge kinds
115    /// responsible for the reach a fan-in brake cannot see. Distinct REFERRING
116    /// FILES, not distinct edges: one neighbour naming a hub fifty times is
117    /// still one neighbour. A file's references to ITSELF are excluded -- a
118    /// file is never its own dependant.
119    pub hub_indegree: HashMap<String, usize>,
120}
121
122impl<'g> GraphIndex<'g> {
123    /// Look up a def by id, e.g. for rendering an ambiguous candidate's
124    /// `{file, line, kind}`. Spelled as a method so callers don't reach through
125    /// `by_id` plus index arithmetic themselves.
126    pub fn def(&self, id: &str) -> Option<&graph::Def> {
127        self.by_id.get(id).map(|&i| &self.graph.defs[i])
128    }
129
130    /// Whether `file` counts as a TEST file for `tests`/`impact`: either an
131    /// attribute-vouched def is declared in it (`test_defs_by_file`), or the
132    /// project model places it inside a unit marked `test`. The second vouch
133    /// fires even for a file that declares no attributed test method at all --
134    /// a harness or fixture file living in a test project is still part of
135    /// what a symbol's tests touch. Fails open exactly like the rest of the
136    /// project model: with no model at all, or a file no discovered project
137    /// owns, this half answers `false` and the attribute vouch alone decides.
138    pub fn is_test_file(&self, file: &str) -> bool {
139        if self.test_defs_by_file.contains_key(file) {
140            return true;
141        }
142        let Some(project) = &self.project else {
143            return false;
144        };
145        project
146            .unit_of_file(file)
147            .is_some_and(|u| project.units[u].test)
148    }
149}
150
151fn note_file(flagged: &mut HashSet<String>, manifest_paths: Option<&HashSet<String>>, file: &str) {
152    if let Some(paths) = manifest_paths {
153        if !file.is_empty() && !paths.contains(file) {
154            flagged.insert(file.to_string());
155        }
156    }
157}
158
159/// What the caller wants left OUT of the index it is asking for.
160///
161/// The one knob is `include_guesses`, and it is spent HERE rather than at each
162/// render site on purpose: a filter applied while the adjacency is built cannot
163/// be forgotten by the next consumer of that adjacency, which is the same
164/// reasoning that keeps heuristic edges in their own buckets in the first place
165/// (see [`GraphIndex::heuristic_inbound`]).
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct IndexOptions {
168    /// Whether a scored-tier guess joins the heuristic adjacency at all.
169    /// `false` (`--no-guess`) admits ONLY [`graph::HeuristicTier::Ext`] edges
170    /// there: extension-method lookup is one of C#'s own rules, run over a
171    /// recorded bucket and unverifiable only against an out-of-graph receiver,
172    /// whereas the scored tier picked by name. An edge carrying no tier at all
173    /// -- a graph written before schema 2 -- is treated as the weaker of the
174    /// two and drops with the guesses.
175    pub include_guesses: bool,
176}
177
178impl Default for IndexOptions {
179    /// Guesses are IN by default: every caller that does not ask asked for the
180    /// whole index, and a default that quietly narrowed the answer would change
181    /// what `refs` means without anyone typing a flag.
182    fn default() -> Self {
183        IndexOptions {
184            include_guesses: true,
185        }
186    }
187}
188
189/// Build the query index, phase two of the two-phase build (see module
190/// header). `graph` is what the caller got back from `graph::read_graph(root)`;
191/// `root` is used here only for the manifest join.
192pub fn load_graph_index<'g>(graph: &'g graph::Graph, root: &Path) -> GraphIndex<'g> {
193    load_graph_index_with(graph, root, IndexOptions::default())
194}
195
196/// [`load_graph_index`] with the caller's own [`IndexOptions`]. Everything is
197/// built the same way either way; `opts` only decides which heuristic edges earn
198/// a place in the heuristic adjacency.
199#[allow(
200    clippy::too_many_lines,
201    reason = "one ordered pass building every index table over the same manifest and graph, so the tables stay mutually consistent"
202)]
203pub fn load_graph_index_with<'g>(
204    graph: &'g graph::Graph,
205    root: &Path,
206    opts: IndexOptions,
207) -> GraphIndex<'g> {
208    let manifest_value = match manifest::read_manifest(root) {
209        Ok(v) => v,
210        Err(_) => None, // corrupt manifest.json: fail open, see module header
211    };
212    let manifest_present = manifest_value.is_some();
213    let manifest_paths: Option<HashSet<String>> = manifest_value.as_ref().and_then(|m| {
214        m.get("entries")
215            .and_then(|e| e.as_object())
216            .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect())
217    });
218    let manifest_paths_ref = manifest_paths.as_ref();
219
220    let mut flagged_files: HashSet<String> = HashSet::new();
221    let mut by_id: HashMap<String, usize> = HashMap::with_capacity(graph.defs.len());
222    let mut by_file: HashMap<String, Vec<usize>> = HashMap::new();
223    let mut by_simple_name: HashMap<String, Vec<usize>> = HashMap::new();
224    let mut by_lower_name: HashMap<String, Vec<usize>> = HashMap::new();
225    let mut test_defs_by_file: HashMap<String, Vec<usize>> = HashMap::new();
226
227    for (i, d) in graph.defs.iter().enumerate() {
228        by_id.insert(d.id.clone(), i);
229        note_file(&mut flagged_files, manifest_paths_ref, &d.file);
230        push_ordered_unique(&mut by_file, &d.file, i);
231        for also in &d.also_in {
232            note_file(&mut flagged_files, manifest_paths_ref, &also.file);
233            push_ordered_unique(&mut by_file, &also.file, i);
234        }
235        if !d.test_methods.is_empty() {
236            let mut files: Vec<&str> = vec![d.file.as_str()];
237            for also in &d.also_in {
238                if !files.contains(&also.file.as_str()) {
239                    files.push(also.file.as_str());
240                }
241            }
242            for file in files {
243                test_defs_by_file
244                    .entry(file.to_string())
245                    .or_default()
246                    .push(i);
247            }
248        }
249        by_simple_name.entry(d.name.clone()).or_default().push(i);
250        by_lower_name
251            .entry(d.name.to_lowercase())
252            .or_default()
253            .push(i);
254    }
255
256    let mut inbound: HashMap<String, InboundEntry> = HashMap::new();
257    let mut outbound_by_file: HashMap<String, OutboundEntry> = HashMap::new();
258    let mut ambiguous_by_candidate: HashMap<String, Vec<usize>> = HashMap::new();
259    let mut ambiguous_by_file: HashMap<String, Vec<usize>> = HashMap::new();
260    let mut heuristic_inbound: HashMap<String, HeuristicEntry> = HashMap::new();
261    let mut heuristic_outbound_by_file: HashMap<String, HeuristicEntry> = HashMap::new();
262    let mut ctor_di_by_to: HashMap<String, Vec<usize>> = HashMap::new();
263    let mut ctor_di_sites_by_iface: HashMap<String, HashSet<(String, usize)>> = HashMap::new();
264    // File -> the distinct other files that reference it (see `hub_indegree`).
265    let mut hub_referrers_by_file: HashMap<String, HashSet<String>> = HashMap::new();
266
267    // Whether a heuristic edge earns a place in the heuristic adjacency at all.
268    // Under `--no-guess` only the extension tier does; every other guess is
269    // dropped from the index, so no consumer downstream has to remember to ask.
270    // It is asked ONLY of an edge already known to be heuristic -- a precise
271    // edge carries no tier and this would wrongly refuse it.
272    let admits =
273        |e: &graph::Edge| opts.include_guesses || e.tier() == Some(graph::HeuristicTier::Ext);
274
275    for (i, e) in graph.edges.iter().enumerate() {
276        match e {
277            graph::Edge::Imports { from_file, .. } => {
278                note_file(&mut flagged_files, manifest_paths_ref, from_file);
279                outbound_by_file
280                    .entry(from_file.clone())
281                    .or_default()
282                    .imports
283                    .push(i);
284            }
285            graph::Edge::Ambiguous {
286                from_file,
287                candidates,
288                ..
289            } => {
290                note_file(&mut flagged_files, manifest_paths_ref, from_file);
291                ambiguous_by_file
292                    .entry(from_file.clone())
293                    .or_default()
294                    .push(i);
295                for c in candidates {
296                    ambiguous_by_candidate
297                        .entry(c.id.clone())
298                        .or_default()
299                        .push(i);
300                }
301            }
302            graph::Edge::Inherits {
303                from_file,
304                to,
305                to_file,
306                heuristic,
307                ..
308            } => {
309                note_file(&mut flagged_files, manifest_paths_ref, from_file);
310                note_file(&mut flagged_files, manifest_paths_ref, to_file);
311                // Counted BEFORE the heuristic split below: the hub brake spans
312                // both kinds on purpose, because a hub file is reached through
313                // whichever of them the extractor happened to resolve.
314                if !to_file.is_empty() && !from_file.is_empty() && to_file != from_file {
315                    hub_referrers_by_file
316                        .entry(to_file.clone())
317                        .or_default()
318                        .insert(from_file.clone());
319                }
320                if *heuristic {
321                    // A guess `--no-guess` refuses leaves the index with no
322                    // bucket at all: it was already counted for the hub brake
323                    // above, and it is not a precise edge, so there is nowhere
324                    // else for it to go.
325                    if admits(e) {
326                        heuristic_outbound_by_file
327                            .entry(from_file.clone())
328                            .or_default()
329                            .inherits
330                            .push(i);
331                        heuristic_inbound
332                            .entry(to.clone())
333                            .or_default()
334                            .inherits
335                            .push(i);
336                    }
337                } else {
338                    outbound_by_file
339                        .entry(from_file.clone())
340                        .or_default()
341                        .inherits
342                        .push(i);
343                    inbound.entry(to.clone()).or_default().inherits.push(i);
344                }
345            }
346            graph::Edge::UsesType {
347                from_file,
348                to,
349                to_file,
350                heuristic,
351                ..
352            } => {
353                note_file(&mut flagged_files, manifest_paths_ref, from_file);
354                note_file(&mut flagged_files, manifest_paths_ref, to_file);
355                // Counted BEFORE the heuristic split below: the hub brake spans
356                // both kinds on purpose, because a hub file is reached through
357                // whichever of them the extractor happened to resolve.
358                if !to_file.is_empty() && !from_file.is_empty() && to_file != from_file {
359                    hub_referrers_by_file
360                        .entry(to_file.clone())
361                        .or_default()
362                        .insert(from_file.clone());
363                }
364                if *heuristic {
365                    // Same rule as the `inherits` arm above.
366                    if admits(e) {
367                        heuristic_outbound_by_file
368                            .entry(from_file.clone())
369                            .or_default()
370                            .uses_type
371                            .push(i);
372                        heuristic_inbound
373                            .entry(to.clone())
374                            .or_default()
375                            .uses_type
376                            .push(i);
377                    }
378                } else {
379                    outbound_by_file
380                        .entry(from_file.clone())
381                        .or_default()
382                        .uses_type
383                        .push(i);
384                    inbound.entry(to.clone()).or_default().uses_type.push(i);
385                }
386            }
387            graph::Edge::UsesMember {
388                from_file,
389                to,
390                to_file,
391                heuristic,
392                ..
393            } => {
394                note_file(&mut flagged_files, manifest_paths_ref, from_file);
395                note_file(&mut flagged_files, manifest_paths_ref, to_file);
396                // Counted BEFORE the heuristic split below: the hub brake spans
397                // both kinds on purpose, because a hub file is reached through
398                // whichever of them the extractor happened to resolve.
399                if !to_file.is_empty() && !from_file.is_empty() && to_file != from_file {
400                    hub_referrers_by_file
401                        .entry(to_file.clone())
402                        .or_default()
403                        .insert(from_file.clone());
404                }
405                if *heuristic {
406                    // The one kind that actually carries a tier, and so the one
407                    // kind `--no-guess` can keep anything of: an extension-tier
408                    // edge survives here, a scored one does not.
409                    if admits(e) {
410                        heuristic_outbound_by_file
411                            .entry(from_file.clone())
412                            .or_default()
413                            .uses_member
414                            .push(i);
415                        heuristic_inbound
416                            .entry(to.clone())
417                            .or_default()
418                            .uses_member
419                            .push(i);
420                    }
421                } else {
422                    outbound_by_file
423                        .entry(from_file.clone())
424                        .or_default()
425                        .uses_member
426                        .push(i);
427                    inbound.entry(to.clone()).or_default().uses_member.push(i);
428                }
429            }
430            // 'ctor-di' is deliberately NOT one of the kinds `refs`/`impact`
431            // render -- that kind list is fixed to
432            // inherits/uses-type/uses-member -- so it earns no `inbound`/
433            // `outbound_by_file` entry. It DOES earn its own reverse index,
434            // keyed by the resolved implementor, when it carries one.
435            graph::Edge::CtorDi {
436                from_file,
437                from_line,
438                iface,
439                to,
440                ..
441            } => {
442                // Fan-in counts EVERY ctor-di edge, resolved or not; only the
443                // reverse index below is restricted to the ones carrying a
444                // confirmed implementor.
445                ctor_di_sites_by_iface
446                    .entry(iface.clone())
447                    .or_default()
448                    .insert((from_file.clone(), *from_line));
449                if let Some(to) = to {
450                    ctor_di_by_to.entry(to.clone()).or_default().push(i);
451                }
452            }
453            // The four TS/TSX edge kinds earn no entry in this C#-shaped index,
454            // the same stance the `ctor-di` arm above states for its own kind:
455            // `refs`/`impact` render a fixed kind list. Listed rather than
456            // caught by a wildcard so a future edge kind still fails the
457            // exhaustiveness check here.
458            graph::Edge::Import { .. }
459            | graph::Edge::Call { .. }
460            | graph::Edge::JsxUse { .. }
461            | graph::Edge::Dispatch { .. } => {}
462        }
463    }
464
465    GraphIndex {
466        graph,
467        root: root.to_path_buf(),
468        by_id,
469        by_file,
470        by_simple_name,
471        by_lower_name,
472        inbound,
473        outbound_by_file,
474        ambiguous_by_candidate,
475        ambiguous_by_file,
476        heuristic_inbound,
477        heuristic_outbound_by_file,
478        test_defs_by_file,
479        flagged_files,
480        manifest_present,
481        ctor_di_by_to,
482        project: if graph.units.is_empty() {
483            None
484        } else {
485            Some(crate::project::ProjectModel::from_units(
486                crate::project::units_from_graph(&graph.units),
487            ))
488        },
489        ctor_di_fanin: ctor_di_sites_by_iface
490            .into_iter()
491            .map(|(name, sites)| (name, sites.len()))
492            .collect(),
493        hub_indegree: hub_referrers_by_file
494            .into_iter()
495            .map(|(file, refs)| (file, refs.len()))
496            .collect(),
497    }
498}
499
500// The interface def id(s) a class def's OWN file(s) declare an `inherits` edge
501// to, restricted to a def of kind `"interface"` -- a plain base class is not
502// part of the interface hop. Reuses `outbound_by_file`'s file-level union
503// rather than attributing an edge to one specific def in a multi-type file (an
504// accepted imprecision). Insertion order is `def_files(def_id)` order, then
505// that file's own inherits-edge array order -- the first-seen order of the
506// `via` labels a widened hit carries.
507pub(super) fn implemented_interfaces(index: &GraphIndex, def_id: &str) -> Vec<String> {
508    let mut seen: SeqSet<String> = SeqSet::new();
509    for file in def_files(index, def_id) {
510        let Some(o) = index.outbound_by_file.get(&file) else {
511            continue;
512        };
513        for &ei in &o.inherits {
514            let graph::Edge::Inherits { to, .. } = &index.graph.edges[ei] else {
515                continue;
516            };
517            if seen.contains(to) {
518                continue;
519            }
520            if index.def(to).map(|d| d.kind.as_str()) != Some("interface") {
521                continue;
522            }
523            seen.insert(to.clone());
524        }
525    }
526    seen.into_vec()
527}
528// ============================================================================
529// def_files / def_sites.
530// ============================================================================
531
532#[derive(Debug, Clone, PartialEq)]
533/// Represents `DefSite`.
534pub struct DefSite {
535    /// The file value.
536    pub file: String,
537    /// The line value.
538    pub line: usize,
539}
540
541/// The distinct files a def is declared in, deduped: a partial type's
542/// `also_in` site can share a file with its primary declaration.
543pub fn def_files(index: &GraphIndex, def_id: &str) -> Vec<String> {
544    let mut out = Vec::new();
545    let mut seen = HashSet::new();
546    if let Some(&i) = index.by_id.get(def_id) {
547        let d = &index.graph.defs[i];
548        if seen.insert(d.file.clone()) {
549            out.push(d.file.clone());
550        }
551        for also in &d.also_in {
552            if seen.insert(also.file.clone()) {
553                out.push(also.file.clone());
554            }
555        }
556    }
557    out
558}
559
560/// Every declaring site of a def, NOT deduped (unlike [`def_files`]) -- each
561/// site is its own row.
562pub fn def_sites(index: &GraphIndex, def_id: &str) -> Vec<DefSite> {
563    let mut out = Vec::new();
564    if let Some(&i) = index.by_id.get(def_id) {
565        let d = &index.graph.defs[i];
566        out.push(DefSite {
567            file: d.file.clone(),
568            line: d.line,
569        });
570        for also in &d.also_in {
571            out.push(DefSite {
572                file: also.file.clone(),
573                line: also.line,
574            });
575        }
576    }
577    out
578}
579
580// ============================================================================
581// symbol_refs.
582// ============================================================================
583
584/// The immediate inbound/outbound edges of one resolved symbol, holding
585/// `graph.edges` indices rather than cloned edges (the same convention
586/// [`GraphIndex`] uses).
587#[derive(Debug, Clone, Default)]
588pub struct SymbolRefs {
589    /// The inbound inherits value.
590    pub inbound_inherits: Vec<usize>,
591    /// The inbound uses type value.
592    pub inbound_uses_type: Vec<usize>,
593    /// The inbound uses member value.
594    pub inbound_uses_member: Vec<usize>,
595    /// The outbound inherits value.
596    pub outbound_inherits: Vec<usize>,
597    /// The outbound uses type value.
598    pub outbound_uses_type: Vec<usize>,
599    /// The outbound uses member value.
600    pub outbound_uses_member: Vec<usize>,
601    /// The outbound imports value.
602    pub outbound_imports: Vec<usize>,
603    /// The same two tables again over the HEURISTIC adjacency, built by the
604    /// identical rules (enum members union into the enum's inbound, partial
605    /// classes union outbound over every declaring file) so a heuristic row is
606    /// never present or absent for a reason a precise row would not have been.
607    pub heuristic_inbound_inherits: Vec<usize>,
608    /// The heuristic inbound uses type value.
609    pub heuristic_inbound_uses_type: Vec<usize>,
610    /// The heuristic inbound uses member value.
611    pub heuristic_inbound_uses_member: Vec<usize>,
612    /// The heuristic outbound inherits value.
613    pub heuristic_outbound_inherits: Vec<usize>,
614    /// The heuristic outbound uses type value.
615    pub heuristic_outbound_uses_type: Vec<usize>,
616    /// The heuristic outbound uses member value.
617    pub heuristic_outbound_uses_member: Vec<usize>,
618    /// The ambiguous inbound value.
619    pub ambiguous_inbound: Vec<usize>,
620    /// The ambiguous outbound value.
621    pub ambiguous_outbound: Vec<usize>,
622}
623
624/// Immediate (1-hop) inbound/outbound for one resolved symbol. Partial-class
625/// defs union outbound over every declaring file. An enum query unions every
626/// member's inbound `uses-member` edges in, iterating `index.graph.defs`
627/// directly (its array order) rather than the unordered `by_id` map.
628pub fn symbol_refs(index: &GraphIndex, def_id: &str) -> SymbolRefs {
629    let empty = InboundEntry::default();
630    let base = index.inbound.get(def_id).unwrap_or(&empty);
631    let inbound_inherits = base.inherits.clone();
632    let inbound_uses_type = base.uses_type.clone();
633    let mut inbound_uses_member = base.uses_member.clone();
634
635    if let Some(&i) = index.by_id.get(def_id) {
636        if index.graph.defs[i].kind == "enum" {
637            let prefix = format!("{def_id}.");
638            for d in &index.graph.defs {
639                if d.kind != "enum-member" || !d.id.starts_with(&prefix) {
640                    continue;
641                }
642                if let Some(m) = index.inbound.get(&d.id) {
643                    inbound_uses_member.extend(m.uses_member.iter().copied());
644                }
645            }
646        }
647    }
648
649    let mut outbound_inherits = Vec::new();
650    let mut outbound_uses_type = Vec::new();
651    let mut outbound_uses_member = Vec::new();
652    let mut outbound_imports = Vec::new();
653    for file in def_files(index, def_id) {
654        if let Some(o) = index.outbound_by_file.get(&file) {
655            outbound_inherits.extend(o.inherits.iter().copied());
656            outbound_uses_type.extend(o.uses_type.iter().copied());
657            outbound_uses_member.extend(o.uses_member.iter().copied());
658            outbound_imports.extend(o.imports.iter().copied());
659        }
660    }
661
662    // The heuristic halves, same rules, separate adjacency.
663    let heuristic_base = index.heuristic_inbound.get(def_id).unwrap_or(&empty);
664    let heuristic_inbound_inherits = heuristic_base.inherits.clone();
665    let heuristic_inbound_uses_type = heuristic_base.uses_type.clone();
666    let mut heuristic_inbound_uses_member = heuristic_base.uses_member.clone();
667
668    if let Some(&i) = index.by_id.get(def_id) {
669        if index.graph.defs[i].kind == "enum" {
670            let prefix = format!("{def_id}.");
671            for d in &index.graph.defs {
672                if d.kind != "enum-member" || !d.id.starts_with(&prefix) {
673                    continue;
674                }
675                if let Some(m) = index.heuristic_inbound.get(&d.id) {
676                    heuristic_inbound_uses_member.extend(m.uses_member.iter().copied());
677                }
678            }
679        }
680    }
681
682    let mut heuristic_outbound_inherits = Vec::new();
683    let mut heuristic_outbound_uses_type = Vec::new();
684    let mut heuristic_outbound_uses_member = Vec::new();
685    for file in def_files(index, def_id) {
686        if let Some(o) = index.heuristic_outbound_by_file.get(&file) {
687            heuristic_outbound_inherits.extend(o.inherits.iter().copied());
688            heuristic_outbound_uses_type.extend(o.uses_type.iter().copied());
689            heuristic_outbound_uses_member.extend(o.uses_member.iter().copied());
690        }
691    }
692
693    let ambiguous_inbound = index
694        .ambiguous_by_candidate
695        .get(def_id)
696        .cloned()
697        .unwrap_or_default();
698    let ambiguous_outbound: Vec<usize> = def_files(index, def_id)
699        .iter()
700        .flat_map(|f| index.ambiguous_by_file.get(f).cloned().unwrap_or_default())
701        .collect();
702
703    SymbolRefs {
704        inbound_inherits,
705        inbound_uses_type,
706        inbound_uses_member,
707        outbound_inherits,
708        outbound_uses_type,
709        outbound_uses_member,
710        outbound_imports,
711        heuristic_inbound_inherits,
712        heuristic_inbound_uses_type,
713        heuristic_inbound_uses_member,
714        heuristic_outbound_inherits,
715        heuristic_outbound_uses_type,
716        heuristic_outbound_uses_member,
717        ambiguous_inbound,
718        ambiguous_outbound,
719    }
720}