Skip to main content

diffkit/
graph.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
2use std::path::{Path, PathBuf};
3
4use crate::model::{
5    CallLabel, CallNode, CallRelation, CallSite, CallSyntax, CallTarget, DispatchCandidate,
6    DispatchResolution, FileAnalysis, FunctionInfo, LanguageFact, SymbolId,
7};
8
9#[derive(Clone, Debug, Default)]
10pub struct ProgramGraph {
11    functions: BTreeMap<SymbolId, FunctionInfo>,
12    index: GraphIndex,
13    facts: Vec<LanguageFact>,
14    source_files: BTreeSet<PathBuf>,
15    declared_roots: BTreeSet<SymbolId>,
16}
17
18/// Immutable lookup data derived from `functions` once at graph construction.
19/// Language frontends only emit facts; traversal never needs to rebuild these
20/// relationships or repeat syntax fallback resolution.
21#[derive(Clone, Debug, Default)]
22struct GraphIndex {
23    functions_by_name: BTreeMap<crate::model::LanguageId, BTreeMap<String, BTreeSet<SymbolId>>>,
24    functions_by_file: BTreeMap<PathBuf, BTreeSet<SymbolId>>,
25    analyzed_files: BTreeSet<PathBuf>,
26    outgoing: BTreeMap<SymbolId, BTreeSet<SymbolId>>,
27    incoming: BTreeMap<SymbolId, BTreeSet<SymbolId>>,
28    expand_call: BTreeMap<SymbolId, Vec<bool>>,
29}
30
31#[derive(Clone, Copy)]
32enum FileScope<'a> {
33    All,
34    Only(Option<&'a BTreeSet<SymbolId>>),
35}
36
37impl FileScope<'_> {
38    fn contains(self, symbol: &SymbolId) -> bool {
39        match self {
40            Self::All => true,
41            Self::Only(symbols) => symbols.is_some_and(|symbols| symbols.contains(symbol)),
42        }
43    }
44}
45
46struct Expansion<'a> {
47    max_depth: usize,
48    file: FileScope<'a>,
49    relevant: Option<&'a BTreeSet<SymbolId>>,
50    context: Option<&'a BTreeSet<SymbolId>>,
51    boundary_marker: Option<&'a str>,
52}
53
54/// Identity of a callee subtree for sibling-call presentation. Every source
55/// call remains visible; only the last equivalent call expands this subtree.
56#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
57enum CallExpansionKey {
58    Direct(SymbolId),
59    Dynamic(SymbolId, Vec<SymbolId>, DispatchResolution),
60}
61
62impl ProgramGraph {
63    pub fn from_files(files: impl IntoIterator<Item = FileAnalysis>) -> Result<Self, String> {
64        let mut graph = Self::default();
65        for file in files {
66            graph.source_files.extend(file.source_files);
67            graph.declared_roots.extend(file.roots);
68            for function in file.functions {
69                graph.source_files.insert(function.span.file.clone());
70                if graph.functions.contains_key(&function.id) {
71                    return Err(format!("duplicate symbol: {}", function.id));
72                }
73                graph.functions.insert(function.id.clone(), function);
74            }
75            graph.facts.extend(file.facts);
76        }
77        graph.index_definitions();
78        graph.resolve_fallback_calls();
79        graph.index_calls();
80        Ok(graph)
81    }
82
83    fn index_definitions(&mut self) {
84        let mut index = GraphIndex::default();
85        index
86            .analyzed_files
87            .extend(self.source_files.iter().cloned());
88        for function in self.functions.values() {
89            let mut names = BTreeSet::from([function.id.name.clone()]);
90            if let Some(leaf) = function.id.name.rsplit("::").next()
91                && leaf != function.id.name
92            {
93                names.insert(leaf.to_owned());
94            }
95            for name in names {
96                index
97                    .functions_by_name
98                    .entry(function.id.language.clone())
99                    .or_default()
100                    .entry(name)
101                    .or_default()
102                    .insert(function.id.clone());
103            }
104            index
105                .functions_by_file
106                .entry(function.span.file.clone())
107                .or_default()
108                .insert(function.id.clone());
109            index.analyzed_files.insert(function.span.file.clone());
110        }
111        let canonical_function_files = index
112            .functions_by_file
113            .iter()
114            .filter_map(|(file, symbols)| {
115                let canonical = file.canonicalize().ok()?;
116                (canonical != *file).then(|| (canonical, symbols.clone()))
117            })
118            .collect::<Vec<_>>();
119        for (file, symbols) in canonical_function_files {
120            index
121                .functions_by_file
122                .entry(file)
123                .or_default()
124                .extend(symbols);
125        }
126        let canonical_analyzed_files = index
127            .analyzed_files
128            .iter()
129            .filter_map(|file| file.canonicalize().ok())
130            .collect::<Vec<_>>();
131        index.analyzed_files.extend(canonical_analyzed_files);
132        self.index = index;
133    }
134
135    fn resolve_fallback_calls(&mut self) {
136        let mut resolutions = Vec::new();
137        for (caller, function) in &self.functions {
138            for (call, site) in function.calls.iter().enumerate() {
139                if matches!(site.target, CallTarget::Unresolved)
140                    && let Some(target) = self.resolve_call(caller, &site.syntax)
141                {
142                    resolutions.push((caller.clone(), call, target));
143                }
144            }
145        }
146        for (caller, call, target) in resolutions {
147            if let Some(site) = self
148                .functions
149                .get_mut(&caller)
150                .and_then(|function| function.calls.get_mut(call))
151            {
152                site.target = CallTarget::Direct(target);
153            }
154        }
155    }
156
157    fn index_calls(&mut self) {
158        for (caller, function) in &self.functions {
159            let mut last = BTreeMap::<CallExpansionKey, usize>::new();
160            let mut expand = vec![true; function.calls.len()];
161            for (call_index, call) in function.calls.iter().enumerate() {
162                for target in call_targets(call) {
163                    if self.functions.contains_key(target) {
164                        self.index
165                            .outgoing
166                            .entry(caller.clone())
167                            .or_default()
168                            .insert(target.clone());
169                        self.index
170                            .incoming
171                            .entry(target.clone())
172                            .or_default()
173                            .insert(caller.clone());
174                    }
175                }
176                if let Some(key) = call_expansion_key(call)
177                    && let Some(previous) = last.insert(key, call_index)
178                {
179                    expand[previous] = false;
180                }
181            }
182            self.index.expand_call.insert(caller.clone(), expand);
183        }
184    }
185
186    pub fn functions(&self) -> &BTreeMap<SymbolId, FunctionInfo> {
187        &self.functions
188    }
189
190    pub fn facts(&self) -> &[LanguageFact] {
191        &self.facts
192    }
193
194    pub fn resolution_diagnostics(&self) -> Vec<String> {
195        let mut diagnostics = BTreeSet::new();
196        for function in self.functions.values() {
197            for call in &function.calls {
198                match &call.target {
199                    CallTarget::Dynamic {
200                        resolution,
201                        evidence,
202                        unresolved_reasons,
203                        ..
204                    } => {
205                        let reasons = unresolved_reasons
206                            .iter()
207                            .map(ToString::to_string)
208                            .collect::<Vec<_>>()
209                            .join(", ");
210                        diagnostics.insert(format!(
211                            "{}:{}:{}: {}: {:?}, evidence={evidence}{}",
212                            call.span.file.display(),
213                            call.span.start_line,
214                            call.span.start_column,
215                            call.label.default,
216                            resolution,
217                            if reasons.is_empty() {
218                                String::new()
219                            } else {
220                                format!(", unresolved={reasons}")
221                            }
222                        ));
223                    }
224                    CallTarget::Indirect { signature, reason } => {
225                        diagnostics.insert(format!(
226                            "{}:{}:{}: {}: indirect{}, unresolved={reason}",
227                            call.span.file.display(),
228                            call.span.start_line,
229                            call.span.start_column,
230                            call.label.default,
231                            signature
232                                .as_ref()
233                                .map_or_else(String::new, |signature| format!(" ({signature})")),
234                        ));
235                    }
236                    // A direct target outside the analyzed graph is a normal external
237                    // leaf, not incomplete resolution. Keep verbose diagnostics focused
238                    // on call sites whose candidate completeness needs explanation.
239                    CallTarget::Direct(_) | CallTarget::Unresolved => {}
240                }
241            }
242        }
243        diagnostics.into_iter().collect()
244    }
245
246    pub fn source_files(&self) -> BTreeSet<PathBuf> {
247        self.source_files.clone()
248    }
249
250    pub fn analyzes_file(&self, file: &Path) -> bool {
251        self.index.analyzed_files.contains(file)
252            || file
253                .canonicalize()
254                .is_ok_and(|file| self.index.analyzed_files.contains(&file))
255    }
256
257    pub fn has_functions_in_file(&self, file: &Path) -> bool {
258        self.symbols_in_file(file)
259            .is_some_and(|symbols| !symbols.is_empty())
260    }
261
262    pub fn public_symbols(&self) -> BTreeSet<SymbolId> {
263        self.functions
264            .values()
265            .filter(|function| function.public)
266            .map(|function| function.id.clone())
267            .collect()
268    }
269
270    /// Return one deterministic root for every source component in the graph.
271    /// Functions with no incoming call are natural roots. A closed recursive
272    /// component has no such function, so its earliest source definition is
273    /// used as that component's presentation root.
274    pub fn inferred_roots(&self) -> BTreeSet<SymbolId> {
275        if !self.declared_roots.is_empty() {
276            return self
277                .declared_roots
278                .iter()
279                .filter(|root| self.functions.contains_key(*root))
280                .cloned()
281                .collect();
282        }
283        let natural = self
284            .functions
285            .keys()
286            .filter(|symbol| !self.index.incoming.contains_key(*symbol))
287            .cloned()
288            .collect::<BTreeSet<_>>();
289        let mut covered = BTreeSet::new();
290        for root in &natural {
291            mark_reachable(root, &self.index.outgoing, &mut covered, FileScope::All);
292        }
293
294        let mut roots = natural;
295        while let Some(uncovered) = self
296            .functions
297            .values()
298            .filter(|function| !covered.contains(&function.id))
299            .min_by_key(|function| {
300                (
301                    function.span.file.clone(),
302                    function.span.start_line,
303                    function.span.start_column,
304                    function.id.clone(),
305                )
306            })
307            .map(|function| function.id.clone())
308        {
309            roots.insert(uncovered.clone());
310            mark_reachable(
311                &uncovered,
312                &self.index.outgoing,
313                &mut covered,
314                FileScope::All,
315            );
316        }
317        roots
318    }
319
320    pub fn roots_in_file(&self, file: &Path) -> BTreeSet<SymbolId> {
321        let Some(functions) = self.symbols_in_file(file) else {
322            return BTreeSet::new();
323        };
324        if functions.is_empty() {
325            return BTreeSet::new();
326        }
327
328        let natural =
329            functions
330                .iter()
331                .filter(|symbol| {
332                    !self.index.incoming.get(*symbol).is_some_and(|callers| {
333                        callers.iter().any(|caller| functions.contains(caller))
334                    })
335                })
336                .cloned()
337                .collect::<BTreeSet<_>>();
338        let scope = FileScope::Only(Some(functions));
339        let mut covered = BTreeSet::new();
340        for root in &natural {
341            mark_reachable(root, &self.index.outgoing, &mut covered, scope);
342        }
343        let mut roots = natural;
344        while let Some(uncovered) = functions.iter().find(|symbol| !covered.contains(*symbol)) {
345            roots.insert(uncovered.clone());
346            mark_reachable(uncovered, &self.index.outgoing, &mut covered, scope);
347        }
348        roots
349    }
350
351    pub fn resolve_entry(&self, entry: &str) -> Result<Option<SymbolId>, String> {
352        let mut matches = self.resolve_entries(entry)?;
353        match matches.len() {
354            0 => Ok(None),
355            1 => Ok(matches.pop()),
356            _ => Err(format!(
357                "entry `{entry}` resolves to multiple call contexts: {}",
358                matches
359                    .iter()
360                    .map(ToString::to_string)
361                    .collect::<Vec<_>>()
362                    .join(", ")
363            )),
364        }
365    }
366
367    /// Resolve an entry to all compiler-proven call contexts of the same
368    /// source function. Ordinary name ambiguity remains an error.
369    pub fn resolve_entries(&self, entry: &str) -> Result<Vec<SymbolId>, String> {
370        let normalized_entry = entry.replace('.', "::");
371        let match_generic_base = !normalized_entry.contains('<');
372        let exact = self
373            .functions
374            .iter()
375            .filter(|(id, _)| {
376                let qualified = id.qualified_parts().join("::");
377                id.to_string() == entry
378                    || id.short_name() == entry
379                    || id.name == entry
380                    || qualified == normalized_entry
381                    || qualified.ends_with(&format!("::{normalized_entry}"))
382            })
383            .map(|(id, _)| id.clone())
384            .collect::<Vec<_>>();
385        let mut matches = if exact.is_empty() {
386            self.functions
387                .iter()
388                .filter(|(id, function)| {
389                    let qualified = id.qualified_parts().join("::");
390                    let qualified_context_base = strip_context_suffix(&qualified);
391                    let name_context_base = strip_context_suffix(&id.name);
392                    let display = callable_prefix(&function.label.default);
393                    display == entry
394                        || display == normalized_entry
395                        || (match_generic_base
396                            && [qualified_context_base, name_context_base, display]
397                                .into_iter()
398                                .flat_map(callable_aliases)
399                                .any(|alias| {
400                                    alias == entry
401                                        || alias == normalized_entry
402                                        || alias.ends_with(&format!("::{normalized_entry}"))
403                                }))
404                })
405                .map(|(id, _)| id.clone())
406                .collect::<Vec<_>>()
407        } else {
408            exact
409        };
410        matches.sort();
411        matches.dedup();
412
413        if matches.len() <= 1 || self.same_contextual_function(&matches) {
414            Ok(matches)
415        } else {
416            Err(format!(
417                "entry `{entry}` is ambiguous: {}",
418                matches
419                    .iter()
420                    .map(ToString::to_string)
421                    .collect::<Vec<_>>()
422                    .join(", ")
423            ))
424        }
425    }
426
427    /// Resolve only definitions whose bodies belong to `file`. This keeps an
428    /// explicit entry in file mode from silently selecting an equally named
429    /// function in another project file.
430    pub fn resolve_entries_in_file(
431        &self,
432        entry: &str,
433        file: &Path,
434    ) -> Result<Vec<SymbolId>, String> {
435        let matches = self.resolve_entries(entry)?;
436        let scope = self.file_scope(Some(file));
437        Ok(matches
438            .into_iter()
439            .filter(|symbol| scope.contains(symbol))
440            .collect())
441    }
442
443    fn same_contextual_function(&self, symbols: &[SymbolId]) -> bool {
444        let Some((first_symbol, remaining)) = symbols.split_first() else {
445            return true;
446        };
447        let Some(first) = self.functions.get(first_symbol) else {
448            return false;
449        };
450        let first_display = strip_generic_arguments(callable_prefix(&first.label.default));
451        remaining.iter().all(|symbol| {
452            let Some(function) = self.functions.get(symbol) else {
453                return false;
454            };
455            strip_generic_arguments(callable_prefix(&function.label.default)) == first_display
456                && function.span == first.span
457        })
458    }
459
460    pub fn build_call_tree(&self, entry: &SymbolId, max_depth: usize) -> Option<CallNode> {
461        self.build_call_tree_in_file(entry, max_depth, None)
462    }
463
464    pub fn build_call_tree_in_file(
465        &self,
466        entry: &SymbolId,
467        max_depth: usize,
468        file: Option<&Path>,
469    ) -> Option<CallNode> {
470        self.functions.get(entry)?;
471        let expansion = Expansion {
472            max_depth,
473            file: self.file_scope(file),
474            relevant: None,
475            context: None,
476            boundary_marker: None,
477        };
478        Some(self.expand(
479            entry,
480            None,
481            CallRelation::Call,
482            0,
483            &expansion,
484            &mut HashSet::new(),
485        ))
486    }
487
488    pub(crate) fn build_diff_call_tree(
489        &self,
490        entry: &SymbolId,
491        max_depth: usize,
492        file: Option<&Path>,
493        relevant: &BTreeSet<SymbolId>,
494        context: &BTreeSet<SymbolId>,
495        boundary_marker: &str,
496    ) -> Option<CallNode> {
497        self.functions.get(entry)?;
498        let expansion = Expansion {
499            max_depth,
500            file: self.file_scope(file),
501            relevant: Some(relevant),
502            context: Some(context),
503            boundary_marker: Some(boundary_marker),
504        };
505        Some(self.expand(
506            entry,
507            None,
508            CallRelation::Call,
509            0,
510            &expansion,
511            &mut HashSet::new(),
512        ))
513    }
514
515    /// A function's local call shape, without expanding callees. Dynamic
516    /// dispatch candidates remain part of the shape because changing the
517    /// candidate set is itself a call-graph change.
518    pub(crate) fn local_call_shape(
519        &self,
520        symbol: &SymbolId,
521        file: Option<&Path>,
522    ) -> Option<CallNode> {
523        let function = self.functions.get(symbol)?;
524        let expansion = Expansion {
525            max_depth: 2,
526            file: self.file_scope(file),
527            relevant: None,
528            context: None,
529            boundary_marker: None,
530        };
531        let mut visiting = HashSet::from([symbol.clone()]);
532        let children = function
533            .calls
534            .iter()
535            .map(|call| {
536                let dynamic = matches!(call.target, CallTarget::Dynamic { .. });
537                let mut node = self.expand_call(symbol, call, 1, &expansion, true, &mut visiting);
538                if !dynamic {
539                    node.children.clear();
540                } else {
541                    for candidate in &mut node.children {
542                        candidate.children.clear();
543                    }
544                }
545                node
546            })
547            .collect();
548        Some(CallNode {
549            key: symbol.to_string(),
550            callsite: None,
551            // Declaration labels are only visible when the function itself is
552            // selected as an entry. They must not propagate to every caller.
553            label: CallLabel::new(""),
554            relation: CallRelation::Call,
555            children,
556        })
557    }
558
559    /// Return every function in this graph that can reach one of `targets`.
560    /// This graph-level pass is linear in the call graph and avoids first
561    /// materializing a potentially exponential call tree.
562    pub(crate) fn symbols_reaching(
563        &self,
564        targets: &BTreeSet<SymbolId>,
565        file: Option<&Path>,
566    ) -> BTreeSet<SymbolId> {
567        let scope = self.file_scope(file);
568
569        let mut reaching = BTreeSet::new();
570        let mut pending = targets
571            .iter()
572            .filter(|target| scope.contains(target))
573            .cloned()
574            .collect::<VecDeque<_>>();
575        while let Some(symbol) = pending.pop_front() {
576            if !reaching.insert(symbol.clone()) {
577                continue;
578            }
579            if let Some(callers) = self.index.incoming.get(&symbol) {
580                pending.extend(
581                    callers
582                        .iter()
583                        .filter(|caller| scope.contains(caller))
584                        .cloned(),
585                );
586            }
587        }
588        reaching
589    }
590
591    pub(crate) fn dispatch_context_symbols(&self, file: Option<&Path>) -> BTreeSet<SymbolId> {
592        let scope = self.file_scope(file);
593        let dispatchers = self
594            .functions
595            .iter()
596            .filter(|(symbol, function)| {
597                scope.contains(symbol)
598                    && function
599                        .calls
600                        .iter()
601                        .any(|call| matches!(call.target, CallTarget::Dynamic { .. }))
602            })
603            .map(|(symbol, _)| symbol.clone())
604            .collect();
605        self.symbols_reaching(&dispatchers, file)
606    }
607
608    #[allow(clippy::too_many_arguments)]
609    fn expand(
610        &self,
611        symbol: &SymbolId,
612        callsite_label: Option<&CallLabel>,
613        relation: CallRelation,
614        depth: usize,
615        expansion: &Expansion<'_>,
616        visiting: &mut HashSet<SymbolId>,
617    ) -> CallNode {
618        let function = self
619            .functions
620            .get(symbol)
621            .expect("expanded symbols must exist in the function index");
622        let label = callsite_label
623            .cloned()
624            .unwrap_or_else(|| function.label.clone());
625
626        if depth >= expansion.max_depth {
627            let children = expansion
628                .boundary_marker
629                .filter(|_| {
630                    expansion
631                        .relevant
632                        .is_some_and(|relevant| relevant.contains(symbol))
633                })
634                .map(|marker| CallNode {
635                    key: format!("{symbol}#{marker}"),
636                    callsite: None,
637                    label: CallLabel::new(""),
638                    relation: CallRelation::Call,
639                    children: Vec::new(),
640                })
641                .into_iter()
642                .collect();
643            return CallNode {
644                key: symbol.to_string(),
645                callsite: None,
646                label,
647                relation,
648                children,
649            };
650        }
651
652        if !visiting.insert(symbol.clone()) {
653            return CallNode {
654                key: symbol.to_string(),
655                callsite: None,
656                label,
657                relation: CallRelation::BackEdge,
658                children: Vec::new(),
659            };
660        }
661
662        let expand_calls = self.index.expand_call.get(symbol);
663        let children = function
664            .calls
665            .iter()
666            .enumerate()
667            .map(|(index, call)| {
668                let expand_target = expand_calls
669                    .and_then(|calls| calls.get(index))
670                    .copied()
671                    .unwrap_or(true);
672                self.expand_call(symbol, call, depth + 1, expansion, expand_target, visiting)
673            })
674            .collect();
675
676        visiting.remove(symbol);
677        CallNode {
678            key: symbol.to_string(),
679            callsite: None,
680            label,
681            relation,
682            children,
683        }
684    }
685
686    fn expand_call(
687        &self,
688        caller: &SymbolId,
689        call: &CallSite,
690        depth: usize,
691        expansion: &Expansion<'_>,
692        expand_target: bool,
693        visiting: &mut HashSet<SymbolId>,
694    ) -> CallNode {
695        match &call.target {
696            CallTarget::Dynamic {
697                dispatch,
698                candidates,
699                resolution,
700                ..
701            } => {
702                let mut node = self.expand_dynamic_call(
703                    dispatch,
704                    &call.label,
705                    candidates,
706                    *resolution,
707                    depth,
708                    expansion,
709                    expand_target,
710                    visiting,
711                );
712                node.callsite = Some(call.id.clone());
713                node
714            }
715            CallTarget::Direct(target) => {
716                let mut node = self.expand_direct_call(
717                    target.clone(),
718                    &call.label,
719                    CallRelation::Call,
720                    depth,
721                    expansion,
722                    expand_target,
723                    visiting,
724                );
725                node.callsite = Some(call.id.clone());
726                node
727            }
728            CallTarget::Indirect { .. } => CallNode {
729                key: format!("{}://indirect/{}", caller.language, call.id.0),
730                callsite: Some(call.id.clone()),
731                label: call.label.with_suffix(" [indirect]"),
732                relation: CallRelation::Call,
733                children: Vec::new(),
734            },
735            CallTarget::Unresolved => {
736                let target = self.resolve_call(caller, &call.syntax);
737                if let Some(target) = target {
738                    let mut node = self.expand_direct_call(
739                        target,
740                        &call.label,
741                        CallRelation::Call,
742                        depth,
743                        expansion,
744                        expand_target,
745                        visiting,
746                    );
747                    node.callsite = Some(call.id.clone());
748                    node
749                } else {
750                    CallNode {
751                        key: format!("{}://?{}", caller.language, call.syntax.key_fragment()),
752                        callsite: Some(call.id.clone()),
753                        label: call.label.clone(),
754                        relation: CallRelation::Call,
755                        children: Vec::new(),
756                    }
757                }
758            }
759        }
760    }
761
762    #[allow(clippy::too_many_arguments)]
763    fn expand_direct_call(
764        &self,
765        target: SymbolId,
766        label: &CallLabel,
767        relation: CallRelation,
768        depth: usize,
769        expansion: &Expansion<'_>,
770        expand_target: bool,
771        visiting: &mut HashSet<SymbolId>,
772    ) -> CallNode {
773        let can_expand = self.functions.contains_key(&target)
774            && expansion.file.contains(&target)
775            && expansion.relevant.is_none_or(|relevant| {
776                relevant.contains(&target)
777                    || expansion
778                        .context
779                        .is_some_and(|context| context.contains(&target))
780            });
781        if can_expand && expand_target {
782            self.expand(&target, Some(label), relation, depth, expansion, visiting)
783        } else {
784            CallNode {
785                key: target.to_string(),
786                callsite: None,
787                label: label.clone(),
788                relation,
789                children: Vec::new(),
790            }
791        }
792    }
793
794    #[allow(clippy::too_many_arguments)]
795    fn expand_dynamic_call(
796        &self,
797        dispatch: &SymbolId,
798        label: &CallLabel,
799        candidates: &[DispatchCandidate],
800        resolution: DispatchResolution,
801        depth: usize,
802        expansion: &Expansion<'_>,
803        expand_target: bool,
804        visiting: &mut HashSet<SymbolId>,
805    ) -> CallNode {
806        let children = if !expand_target
807            || depth >= expansion.max_depth
808            || resolution == DispatchResolution::Unresolved
809        {
810            Vec::new()
811        } else {
812            candidates
813                .iter()
814                .map(|candidate| {
815                    self.expand_direct_call(
816                        candidate.target.clone(),
817                        &candidate.label,
818                        CallRelation::DispatchCandidate,
819                        depth + 1,
820                        expansion,
821                        true,
822                        visiting,
823                    )
824                })
825                .chain(
826                    (resolution == DispatchResolution::Partial).then(|| CallNode {
827                        key: format!("{dispatch}#unresolved"),
828                        callsite: None,
829                        label: CallLabel::new("… unresolved targets"),
830                        relation: CallRelation::DispatchCandidate,
831                        children: Vec::new(),
832                    }),
833                )
834                .collect()
835        };
836
837        let label = match resolution {
838            DispatchResolution::Complete => label.clone(),
839            DispatchResolution::Partial => label.with_suffix(" [partial]"),
840            DispatchResolution::Unresolved => label.with_suffix(" [unresolved]"),
841        };
842
843        CallNode {
844            key: dispatch.to_string(),
845            callsite: None,
846            label,
847            relation: CallRelation::Call,
848            children,
849        }
850    }
851
852    fn resolve_call(&self, caller: &SymbolId, call: &CallSyntax) -> Option<SymbolId> {
853        if call.requires_compiler_confirmation() {
854            return None;
855        }
856        let mut preferred = Vec::new();
857
858        match call.visible() {
859            CallSyntax::SelfMethod(method) => {
860                if let Some(container) = &caller.container {
861                    preferred.extend(self.named_symbols(&caller.language, method).filter(
862                        |candidate| {
863                            candidate.module == caller.module
864                                && candidate.container.as_ref() == Some(container)
865                        },
866                    ));
867                }
868            }
869            CallSyntax::Path(parts) if !parts.is_empty() => {
870                let name = parts.last().expect("non-empty path");
871                if parts.first().is_some_and(|part| part == "Self") {
872                    if let Some(container) = &caller.container {
873                        preferred.extend(self.named_symbols(&caller.language, name).filter(
874                            |candidate| {
875                                candidate.module == caller.module
876                                    && candidate.container.as_ref() == Some(container)
877                            },
878                        ));
879                    }
880                } else if parts.len() == 1 {
881                    preferred.extend(self.named_symbols(&caller.language, name).filter(
882                        |candidate| {
883                            candidate.module == caller.module && candidate.container.is_none()
884                        },
885                    ));
886                } else {
887                    preferred.extend(
888                        self.named_symbols(&caller.language, name)
889                            .filter(|candidate| path_suffix_matches(candidate, parts)),
890                    );
891                }
892            }
893            CallSyntax::Method { receiver, method } => {
894                // Static-looking receiver names can be matched without type inference.
895                if receiver.chars().next().is_some_and(char::is_uppercase) {
896                    preferred.extend(self.named_symbols(&caller.language, method).filter(
897                        |candidate| {
898                            candidate
899                                .container
900                                .as_deref()
901                                .is_some_and(|container| base_container(container) == receiver)
902                        },
903                    ));
904                }
905            }
906            CallSyntax::Path(_) | CallSyntax::Expression(_) | CallSyntax::CompilerConfirmed(_) => {}
907        }
908
909        let preferred = unique(preferred);
910        if preferred.len() == 1 {
911            return preferred.into_iter().next().cloned();
912        }
913        if preferred.len() > 1 {
914            return None;
915        }
916        None
917    }
918
919    fn named_symbols<'a>(
920        &'a self,
921        language: &crate::model::LanguageId,
922        name: &str,
923    ) -> impl Iterator<Item = &'a SymbolId> {
924        self.index
925            .functions_by_name
926            .get(language)
927            .and_then(|names| names.get(name))
928            .into_iter()
929            .flatten()
930    }
931
932    fn symbols_in_file(&self, file: &Path) -> Option<&BTreeSet<SymbolId>> {
933        self.index.functions_by_file.get(file).or_else(|| {
934            file.canonicalize()
935                .ok()
936                .and_then(|file| self.index.functions_by_file.get(&file))
937        })
938    }
939
940    fn file_scope(&self, file: Option<&Path>) -> FileScope<'_> {
941        match file {
942            Some(file) => FileScope::Only(self.symbols_in_file(file)),
943            None => FileScope::All,
944        }
945    }
946}
947
948fn mark_reachable(
949    root: &SymbolId,
950    adjacency: &BTreeMap<SymbolId, BTreeSet<SymbolId>>,
951    covered: &mut BTreeSet<SymbolId>,
952    scope: FileScope<'_>,
953) {
954    let mut pending = vec![root.clone()];
955    while let Some(symbol) = pending.pop() {
956        if !scope.contains(&symbol) || !covered.insert(symbol.clone()) {
957            continue;
958        }
959        if let Some(targets) = adjacency.get(&symbol) {
960            pending.extend(targets.iter().rev().cloned());
961        }
962    }
963}
964
965fn call_targets(call: &CallSite) -> impl Iterator<Item = &SymbolId> {
966    let direct = match &call.target {
967        CallTarget::Direct(target) => Some(target),
968        _ => None,
969    };
970    let dynamic = match &call.target {
971        CallTarget::Dynamic { candidates, .. } => candidates.as_slice(),
972        _ => &[],
973    };
974    direct
975        .into_iter()
976        .chain(dynamic.iter().map(|candidate| &candidate.target))
977}
978
979fn call_expansion_key(call: &CallSite) -> Option<CallExpansionKey> {
980    match &call.target {
981        CallTarget::Direct(target) => Some(CallExpansionKey::Direct(target.clone())),
982        CallTarget::Dynamic {
983            dispatch,
984            candidates,
985            resolution,
986            ..
987        } => Some(CallExpansionKey::Dynamic(
988            dispatch.clone(),
989            candidates
990                .iter()
991                .map(|candidate| candidate.target.clone())
992                .collect(),
993            *resolution,
994        )),
995        CallTarget::Indirect { .. } | CallTarget::Unresolved => None,
996    }
997}
998
999fn callable_prefix(label: &str) -> &str {
1000    let mut depth = 0usize;
1001    for (index, character) in label.char_indices().rev() {
1002        match character {
1003            ')' => depth += 1,
1004            '(' => {
1005                let Some(next_depth) = depth.checked_sub(1) else {
1006                    return label;
1007                };
1008                depth = next_depth;
1009                if depth == 0 {
1010                    return &label[..index];
1011                }
1012            }
1013            _ => {}
1014        }
1015    }
1016    label
1017}
1018
1019fn strip_context_suffix(value: &str) -> &str {
1020    value.split_once("#ctx[").map_or(value, |(base, _)| base)
1021}
1022
1023fn strip_generic_arguments(value: &str) -> String {
1024    let mut stripped = String::with_capacity(value.len());
1025    let mut depth = 0usize;
1026    for character in value.chars() {
1027        match character {
1028            '<' => depth += 1,
1029            '>' if depth > 0 => depth -= 1,
1030            _ if depth == 0 => stripped.push(character),
1031            _ => {}
1032        }
1033    }
1034    stripped
1035}
1036
1037fn callable_aliases(value: &str) -> Vec<String> {
1038    if let Some((self_type, trait_name, method)) = qualified_trait_callable(value) {
1039        let self_type = strip_generic_arguments(self_type);
1040        let trait_name = strip_generic_arguments(trait_name);
1041        let method = strip_generic_arguments(method);
1042        return vec![
1043            format!("{self_type} as {trait_name}::{method}"),
1044            format!("{self_type}::{method}"),
1045            format!("{trait_name}::{method}"),
1046        ];
1047    }
1048    let callable = strip_generic_arguments(value);
1049    let mut aliases = vec![callable.clone()];
1050    if let Some((self_type, trait_method)) = callable.split_once(" as ")
1051        && let Some((_, method)) = trait_method.rsplit_once("::")
1052    {
1053        aliases.push(format!("{self_type}::{method}"));
1054        aliases.push(trait_method.to_owned());
1055    }
1056    aliases
1057}
1058
1059fn qualified_trait_callable(value: &str) -> Option<(&str, &str, &str)> {
1060    if !value.starts_with('<') {
1061        return None;
1062    }
1063    let mut depth = 0usize;
1064    let mut qualification_end = None;
1065    for (index, character) in value.char_indices() {
1066        match character {
1067            '<' => depth += 1,
1068            '>' => {
1069                depth = depth.checked_sub(1)?;
1070                if depth == 0 {
1071                    qualification_end = Some(index);
1072                    break;
1073                }
1074            }
1075            _ => {}
1076        }
1077    }
1078    let qualification_end = qualification_end?;
1079    let qualification = &value[1..qualification_end];
1080    let method = value[qualification_end + 1..].strip_prefix("::")?;
1081    let (self_type, trait_name) = qualification.rsplit_once(" as ")?;
1082    Some((self_type, trait_name, method))
1083}
1084
1085fn unique<'a>(values: impl IntoIterator<Item = &'a SymbolId>) -> Vec<&'a SymbolId> {
1086    values
1087        .into_iter()
1088        .collect::<BTreeSet<_>>()
1089        .into_iter()
1090        .collect()
1091}
1092
1093fn base_container(container: &str) -> &str {
1094    container
1095        .split_once(" as ")
1096        .map_or(container, |(base, _)| base)
1097}
1098
1099fn path_suffix_matches(candidate: &SymbolId, raw: &[String]) -> bool {
1100    let raw = raw
1101        .iter()
1102        .filter(|part| !matches!(part.as_str(), "crate" | "self" | "super"))
1103        .map(String::as_str)
1104        .collect::<Vec<_>>();
1105    let candidate = candidate.qualified_parts();
1106    raw.len() <= candidate.len() && candidate[candidate.len() - raw.len()..] == raw
1107}