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