Skip to main content

diffkit/
graph.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet};
2
3use crate::model::{
4    CallLabel, CallNode, CallRelation, CallSite, CallSyntax, CallTarget, DispatchCandidate,
5    DispatchResolution, FileAnalysis, FunctionInfo, LanguageFact, SymbolId,
6};
7
8#[derive(Clone, Debug, Default)]
9pub struct ProgramGraph {
10    functions: BTreeMap<SymbolId, FunctionInfo>,
11    facts: Vec<LanguageFact>,
12    source_files: BTreeSet<std::path::PathBuf>,
13    declared_roots: BTreeSet<SymbolId>,
14}
15
16impl ProgramGraph {
17    pub fn from_files(files: impl IntoIterator<Item = FileAnalysis>) -> Result<Self, String> {
18        let mut graph = Self::default();
19        for file in files {
20            graph.source_files.extend(file.source_files);
21            graph.declared_roots.extend(file.roots);
22            for function in file.functions {
23                graph.source_files.insert(function.span.file.clone());
24                if graph.functions.contains_key(&function.id) {
25                    return Err(format!("duplicate symbol: {}", function.id));
26                }
27                graph.functions.insert(function.id.clone(), function);
28            }
29            graph.facts.extend(file.facts);
30        }
31        Ok(graph)
32    }
33
34    pub fn functions(&self) -> &BTreeMap<SymbolId, FunctionInfo> {
35        &self.functions
36    }
37
38    pub fn facts(&self) -> &[LanguageFact] {
39        &self.facts
40    }
41
42    pub fn resolution_diagnostics(&self) -> Vec<String> {
43        let mut diagnostics = BTreeSet::new();
44        for function in self.functions.values() {
45            for call in &function.calls {
46                match &call.target {
47                    CallTarget::Dynamic {
48                        resolution,
49                        evidence,
50                        unresolved_reasons,
51                        ..
52                    } => {
53                        let reasons = unresolved_reasons
54                            .iter()
55                            .map(ToString::to_string)
56                            .collect::<Vec<_>>()
57                            .join(", ");
58                        diagnostics.insert(format!(
59                            "{}:{}:{}: {}: {:?}, evidence={evidence}{}",
60                            call.span.file.display(),
61                            call.span.start_line,
62                            call.span.start_column,
63                            call.label.default,
64                            resolution,
65                            if reasons.is_empty() {
66                                String::new()
67                            } else {
68                                format!(", unresolved={reasons}")
69                            }
70                        ));
71                    }
72                    CallTarget::Indirect { signature, reason } => {
73                        diagnostics.insert(format!(
74                            "{}:{}:{}: {}: indirect{}, unresolved={reason}",
75                            call.span.file.display(),
76                            call.span.start_line,
77                            call.span.start_column,
78                            call.label.default,
79                            signature
80                                .as_ref()
81                                .map_or_else(String::new, |signature| format!(" ({signature})")),
82                        ));
83                    }
84                    // A direct target outside the analyzed graph is a normal external
85                    // leaf, not incomplete resolution. Keep verbose diagnostics focused
86                    // on call sites whose candidate completeness needs explanation.
87                    CallTarget::Direct(_) | CallTarget::Unresolved => {}
88                }
89            }
90        }
91        diagnostics.into_iter().collect()
92    }
93
94    pub fn source_files(&self) -> BTreeSet<std::path::PathBuf> {
95        self.source_files.clone()
96    }
97
98    pub fn analyzes_file(&self, file: &std::path::Path) -> bool {
99        self.source_files
100            .iter()
101            .any(|analyzed| same_file(analyzed, file))
102    }
103
104    pub fn has_functions_in_file(&self, file: &std::path::Path) -> bool {
105        self.functions
106            .values()
107            .any(|function| same_file(&function.span.file, file))
108    }
109
110    pub fn public_symbols(&self) -> BTreeSet<SymbolId> {
111        self.functions
112            .values()
113            .filter(|function| function.public)
114            .map(|function| function.id.clone())
115            .collect()
116    }
117
118    /// Return one deterministic root for every source component in the graph.
119    /// Functions with no incoming call are natural roots. A closed recursive
120    /// component has no such function, so its earliest source definition is
121    /// used as that component's presentation root.
122    pub fn inferred_roots(&self) -> BTreeSet<SymbolId> {
123        if !self.declared_roots.is_empty() {
124            return self
125                .declared_roots
126                .iter()
127                .filter(|root| self.functions.contains_key(*root))
128                .cloned()
129                .collect();
130        }
131        let mut incoming = self
132            .functions
133            .keys()
134            .cloned()
135            .map(|symbol| (symbol, 0usize))
136            .collect::<BTreeMap<_, _>>();
137        let mut adjacency = BTreeMap::<SymbolId, BTreeSet<SymbolId>>::new();
138
139        for (caller, function) in &self.functions {
140            for call in &function.calls {
141                for target in self.call_targets(caller, call) {
142                    if self.functions.contains_key(&target) {
143                        *incoming.entry(target.clone()).or_default() += 1;
144                        adjacency.entry(caller.clone()).or_default().insert(target);
145                    }
146                }
147            }
148        }
149
150        let natural = incoming
151            .iter()
152            .filter_map(|(symbol, count)| (*count == 0).then_some(symbol.clone()))
153            .collect::<BTreeSet<_>>();
154        let mut covered = BTreeSet::new();
155        for root in &natural {
156            mark_reachable(root, &adjacency, &mut covered);
157        }
158
159        let mut roots = natural;
160        while let Some(uncovered) = self
161            .functions
162            .values()
163            .filter(|function| !covered.contains(&function.id))
164            .min_by_key(|function| {
165                (
166                    function.span.file.clone(),
167                    function.span.start_line,
168                    function.span.start_column,
169                    function.id.clone(),
170                )
171            })
172            .map(|function| function.id.clone())
173        {
174            roots.insert(uncovered.clone());
175            mark_reachable(&uncovered, &adjacency, &mut covered);
176        }
177        roots
178    }
179
180    pub fn roots_in_file(&self, file: &std::path::Path) -> BTreeSet<SymbolId> {
181        let functions = self
182            .functions
183            .values()
184            .filter(|function| same_file(&function.span.file, file))
185            .map(|function| function.id.clone())
186            .collect::<BTreeSet<_>>();
187        if functions.is_empty() {
188            return BTreeSet::new();
189        }
190
191        let mut incoming = functions
192            .iter()
193            .cloned()
194            .map(|symbol| (symbol, 0usize))
195            .collect::<BTreeMap<_, _>>();
196        let mut adjacency = BTreeMap::<SymbolId, BTreeSet<SymbolId>>::new();
197        for caller in &functions {
198            let Some(function) = self.functions.get(caller) else {
199                continue;
200            };
201            for call in &function.calls {
202                for target in self.call_targets(caller, call) {
203                    if functions.contains(&target) {
204                        *incoming.entry(target.clone()).or_default() += 1;
205                        adjacency.entry(caller.clone()).or_default().insert(target);
206                    }
207                }
208            }
209        }
210
211        let natural = incoming
212            .iter()
213            .filter_map(|(symbol, count)| (*count == 0).then_some(symbol.clone()))
214            .collect::<BTreeSet<_>>();
215        let mut covered = BTreeSet::new();
216        for root in &natural {
217            mark_reachable(root, &adjacency, &mut covered);
218        }
219        let mut roots = natural;
220        while let Some(uncovered) = functions.iter().find(|symbol| !covered.contains(*symbol)) {
221            roots.insert(uncovered.clone());
222            mark_reachable(uncovered, &adjacency, &mut covered);
223        }
224        roots
225    }
226
227    pub fn resolve_entry(&self, entry: &str) -> Result<Option<SymbolId>, String> {
228        let mut matches = self.resolve_entries(entry)?;
229        match matches.len() {
230            0 => Ok(None),
231            1 => Ok(matches.pop()),
232            _ => Err(format!(
233                "entry `{entry}` resolves to multiple call contexts: {}",
234                matches
235                    .iter()
236                    .map(ToString::to_string)
237                    .collect::<Vec<_>>()
238                    .join(", ")
239            )),
240        }
241    }
242
243    /// Resolve an entry to all compiler-proven call contexts of the same
244    /// source function. Ordinary name ambiguity remains an error.
245    pub fn resolve_entries(&self, entry: &str) -> Result<Vec<SymbolId>, String> {
246        let normalized_entry = entry.replace('.', "::");
247        let mut matches = self
248            .functions
249            .iter()
250            .filter(|(id, function)| {
251                let qualified = id.qualified_parts().join("::");
252                let qualified_base = strip_context_suffix(&qualified);
253                let name_base = strip_context_suffix(&id.name);
254                let display = callable_prefix(&function.label.default);
255                id.to_string() == entry
256                    || id.short_name() == entry
257                    || id.name == entry
258                    || name_base == entry
259                    || qualified == normalized_entry
260                    || qualified_base == normalized_entry
261                    || qualified.ends_with(&format!("::{normalized_entry}"))
262                    || qualified_base.ends_with(&format!("::{normalized_entry}"))
263                    || display == entry
264                    || display == normalized_entry
265            })
266            .map(|(id, _)| id.clone())
267            .collect::<Vec<_>>();
268        matches.sort();
269        matches.dedup();
270
271        if matches.len() <= 1 || self.same_contextual_function(&matches) {
272            Ok(matches)
273        } else {
274            Err(format!(
275                "entry `{entry}` is ambiguous: {}",
276                matches
277                    .iter()
278                    .map(ToString::to_string)
279                    .collect::<Vec<_>>()
280                    .join(", ")
281            ))
282        }
283    }
284
285    /// Resolve only definitions whose bodies belong to `file`. This keeps an
286    /// explicit entry in file mode from silently selecting an equally named
287    /// function in another project file.
288    pub fn resolve_entries_in_file(
289        &self,
290        entry: &str,
291        file: &std::path::Path,
292    ) -> Result<Vec<SymbolId>, String> {
293        let matches = self.resolve_entries(entry)?;
294        Ok(matches
295            .into_iter()
296            .filter(|symbol| {
297                self.functions
298                    .get(symbol)
299                    .is_some_and(|function| same_file(&function.span.file, file))
300            })
301            .collect())
302    }
303
304    fn same_contextual_function(&self, symbols: &[SymbolId]) -> bool {
305        let Some((first_symbol, remaining)) = symbols.split_first() else {
306            return true;
307        };
308        let Some(first) = self.functions.get(first_symbol) else {
309            return false;
310        };
311        let first_key = strip_context_suffix(&first_symbol.name);
312        remaining.iter().all(|symbol| {
313            let Some(function) = self.functions.get(symbol) else {
314                return false;
315            };
316            let key = strip_context_suffix(&symbol.name);
317            key == first_key
318                && function.label.default == first.label.default
319                && function.span == first.span
320        })
321    }
322
323    pub fn build_call_tree(&self, entry: &SymbolId, max_depth: usize) -> Option<CallNode> {
324        self.build_call_tree_in_file(entry, max_depth, None)
325    }
326
327    pub fn build_call_tree_in_file(
328        &self,
329        entry: &SymbolId,
330        max_depth: usize,
331        file: Option<&std::path::Path>,
332    ) -> Option<CallNode> {
333        self.functions.get(entry)?;
334        Some(self.expand(
335            entry,
336            None,
337            CallRelation::Call,
338            0,
339            max_depth,
340            file,
341            &mut HashSet::new(),
342        ))
343    }
344
345    #[allow(clippy::too_many_arguments)]
346    fn expand(
347        &self,
348        symbol: &SymbolId,
349        callsite_label: Option<&CallLabel>,
350        relation: CallRelation,
351        depth: usize,
352        max_depth: usize,
353        file: Option<&std::path::Path>,
354        visiting: &mut HashSet<SymbolId>,
355    ) -> CallNode {
356        let function = self
357            .functions
358            .get(symbol)
359            .expect("expanded symbols must exist in the function index");
360        let label = callsite_label
361            .cloned()
362            .unwrap_or_else(|| function.label.clone());
363
364        if depth >= max_depth {
365            return CallNode {
366                key: symbol.to_string(),
367                callsite: None,
368                label,
369                relation,
370                children: Vec::new(),
371            };
372        }
373
374        if !visiting.insert(symbol.clone()) {
375            return CallNode {
376                key: symbol.to_string(),
377                callsite: None,
378                label: CallLabel::new(""),
379                relation: CallRelation::BackEdge,
380                children: Vec::new(),
381            };
382        }
383
384        let children = function
385            .calls
386            .iter()
387            .map(|call| self.expand_call(symbol, call, depth + 1, max_depth, file, visiting))
388            .collect();
389
390        visiting.remove(symbol);
391        CallNode {
392            key: symbol.to_string(),
393            callsite: None,
394            label,
395            relation,
396            children,
397        }
398    }
399
400    fn expand_call(
401        &self,
402        caller: &SymbolId,
403        call: &CallSite,
404        depth: usize,
405        max_depth: usize,
406        file: Option<&std::path::Path>,
407        visiting: &mut HashSet<SymbolId>,
408    ) -> CallNode {
409        match &call.target {
410            CallTarget::Dynamic {
411                dispatch,
412                candidates,
413                resolution,
414                ..
415            } => {
416                let mut node = self.expand_dynamic_call(
417                    dispatch,
418                    &call.label,
419                    candidates,
420                    *resolution,
421                    depth,
422                    max_depth,
423                    file,
424                    visiting,
425                );
426                node.callsite = Some(call.id.clone());
427                node
428            }
429            CallTarget::Direct(target) => {
430                let mut node = self.expand_direct_call(
431                    target.clone(),
432                    &call.label,
433                    CallRelation::Call,
434                    depth,
435                    max_depth,
436                    file,
437                    visiting,
438                );
439                node.callsite = Some(call.id.clone());
440                node
441            }
442            CallTarget::Indirect { .. } => CallNode {
443                key: format!("{}://indirect/{}", caller.language, call.id.0),
444                callsite: Some(call.id.clone()),
445                label: call.label.with_suffix(" [indirect]"),
446                relation: CallRelation::Call,
447                children: Vec::new(),
448            },
449            CallTarget::Unresolved => {
450                let target = self.resolve_call(caller, &call.syntax);
451                if let Some(target) = target {
452                    let mut node = self.expand_direct_call(
453                        target,
454                        &call.label,
455                        CallRelation::Call,
456                        depth,
457                        max_depth,
458                        file,
459                        visiting,
460                    );
461                    node.callsite = Some(call.id.clone());
462                    node
463                } else {
464                    CallNode {
465                        key: format!("{}://?{}", caller.language, call.syntax.key_fragment()),
466                        callsite: Some(call.id.clone()),
467                        label: call.label.clone(),
468                        relation: CallRelation::Call,
469                        children: Vec::new(),
470                    }
471                }
472            }
473        }
474    }
475
476    #[allow(clippy::too_many_arguments)]
477    fn expand_direct_call(
478        &self,
479        target: SymbolId,
480        label: &CallLabel,
481        relation: CallRelation,
482        depth: usize,
483        max_depth: usize,
484        file: Option<&std::path::Path>,
485        visiting: &mut HashSet<SymbolId>,
486    ) -> CallNode {
487        let can_expand = self
488            .functions
489            .get(&target)
490            .is_some_and(|function| file.is_none_or(|file| same_file(&function.span.file, file)));
491        if can_expand {
492            self.expand(
493                &target,
494                Some(label),
495                relation,
496                depth,
497                max_depth,
498                file,
499                visiting,
500            )
501        } else {
502            CallNode {
503                key: target.to_string(),
504                callsite: None,
505                label: label.clone(),
506                relation,
507                children: Vec::new(),
508            }
509        }
510    }
511
512    #[allow(clippy::too_many_arguments)]
513    fn expand_dynamic_call(
514        &self,
515        dispatch: &SymbolId,
516        label: &CallLabel,
517        candidates: &[DispatchCandidate],
518        resolution: DispatchResolution,
519        depth: usize,
520        max_depth: usize,
521        file: Option<&std::path::Path>,
522        visiting: &mut HashSet<SymbolId>,
523    ) -> CallNode {
524        let children = if depth >= max_depth || resolution == DispatchResolution::Unresolved {
525            Vec::new()
526        } else {
527            candidates
528                .iter()
529                .map(|candidate| {
530                    self.expand_direct_call(
531                        candidate.target.clone(),
532                        &candidate.label,
533                        CallRelation::DispatchCandidate,
534                        depth + 1,
535                        max_depth,
536                        file,
537                        visiting,
538                    )
539                })
540                .chain(
541                    (resolution == DispatchResolution::Partial).then(|| CallNode {
542                        key: format!("{dispatch}#unresolved"),
543                        callsite: None,
544                        label: CallLabel::new("… unresolved targets"),
545                        relation: CallRelation::DispatchCandidate,
546                        children: Vec::new(),
547                    }),
548                )
549                .collect()
550        };
551
552        let label = match resolution {
553            DispatchResolution::Complete => label.clone(),
554            DispatchResolution::Partial => label.with_suffix(" [partial]"),
555            DispatchResolution::Unresolved => label.with_suffix(" [unresolved]"),
556        };
557
558        CallNode {
559            key: dispatch.to_string(),
560            callsite: None,
561            label,
562            relation: CallRelation::Call,
563            children,
564        }
565    }
566
567    fn call_targets(&self, caller: &SymbolId, call: &CallSite) -> Vec<SymbolId> {
568        match &call.target {
569            CallTarget::Direct(target) => vec![target.clone()],
570            CallTarget::Dynamic { candidates, .. } => candidates
571                .iter()
572                .map(|candidate| candidate.target.clone())
573                .collect(),
574            CallTarget::Indirect { .. } => Vec::new(),
575            CallTarget::Unresolved => self
576                .resolve_call(caller, &call.syntax)
577                .into_iter()
578                .collect(),
579        }
580    }
581
582    fn resolve_call(&self, caller: &SymbolId, call: &CallSyntax) -> Option<SymbolId> {
583        let mut preferred = Vec::new();
584
585        match call {
586            CallSyntax::SelfMethod(method) => {
587                if let Some(container) = &caller.container {
588                    preferred.extend(self.functions.keys().filter(|candidate| {
589                        candidate.language == caller.language
590                            && candidate.module == caller.module
591                            && candidate.container.as_ref() == Some(container)
592                            && candidate.name == *method
593                    }));
594                }
595            }
596            CallSyntax::Path(parts) if !parts.is_empty() => {
597                let name = parts.last().expect("non-empty path");
598                if parts.first().is_some_and(|part| part == "Self") {
599                    if let Some(container) = &caller.container {
600                        preferred.extend(self.functions.keys().filter(|candidate| {
601                            candidate.language == caller.language
602                                && candidate.module == caller.module
603                                && candidate.container.as_ref() == Some(container)
604                                && candidate.name == *name
605                        }));
606                    }
607                } else if parts.len() == 1 {
608                    preferred.extend(self.functions.keys().filter(|candidate| {
609                        candidate.language == caller.language
610                            && candidate.module == caller.module
611                            && candidate.container.is_none()
612                            && candidate.name == *name
613                    }));
614                } else {
615                    preferred.extend(self.functions.keys().filter(|candidate| {
616                        candidate.language == caller.language
617                            && candidate.name == *name
618                            && path_suffix_matches(candidate, parts)
619                    }));
620                }
621            }
622            CallSyntax::Method { receiver, method } => {
623                // Static-looking receiver names can be matched without type inference.
624                if receiver.chars().next().is_some_and(char::is_uppercase) {
625                    preferred.extend(self.functions.keys().filter(|candidate| {
626                        candidate.language == caller.language
627                            && candidate.name == *method
628                            && candidate
629                                .container
630                                .as_deref()
631                                .is_some_and(|container| base_container(container) == receiver)
632                    }));
633                }
634            }
635            CallSyntax::Path(_) => {}
636        }
637
638        let preferred = unique(preferred);
639        if preferred.len() == 1 {
640            return preferred.into_iter().next().cloned();
641        }
642        if preferred.len() > 1 {
643            return None;
644        }
645
646        let name = match call {
647            CallSyntax::Path(parts) => parts.last()?,
648            CallSyntax::SelfMethod(method) | CallSyntax::Method { method, .. } => method,
649        };
650        let fallback =
651            unique(self.functions.keys().filter(|candidate| {
652                candidate.language == caller.language && candidate.name == *name
653            }));
654        (fallback.len() == 1).then(|| fallback[0].clone())
655    }
656}
657
658fn mark_reachable(
659    root: &SymbolId,
660    adjacency: &BTreeMap<SymbolId, BTreeSet<SymbolId>>,
661    covered: &mut BTreeSet<SymbolId>,
662) {
663    if !covered.insert(root.clone()) {
664        return;
665    }
666    if let Some(targets) = adjacency.get(root) {
667        for target in targets {
668            mark_reachable(target, adjacency, covered);
669        }
670    }
671}
672
673fn same_file(left: &std::path::Path, right: &std::path::Path) -> bool {
674    left == right
675        || left
676            .canonicalize()
677            .ok()
678            .zip(right.canonicalize().ok())
679            .is_some_and(|(left, right)| left == right)
680}
681
682fn callable_prefix(label: &str) -> &str {
683    let mut depth = 0usize;
684    for (index, character) in label.char_indices().rev() {
685        match character {
686            ')' => depth += 1,
687            '(' => {
688                let Some(next_depth) = depth.checked_sub(1) else {
689                    return label;
690                };
691                depth = next_depth;
692                if depth == 0 {
693                    return &label[..index];
694                }
695            }
696            _ => {}
697        }
698    }
699    label
700}
701
702fn strip_context_suffix(value: &str) -> &str {
703    value.split_once("#ctx[").map_or(value, |(base, _)| base)
704}
705
706fn unique<'a>(values: impl IntoIterator<Item = &'a SymbolId>) -> Vec<&'a SymbolId> {
707    values
708        .into_iter()
709        .collect::<BTreeSet<_>>()
710        .into_iter()
711        .collect()
712}
713
714fn base_container(container: &str) -> &str {
715    container
716        .split_once(" as ")
717        .map_or(container, |(base, _)| base)
718}
719
720fn path_suffix_matches(candidate: &SymbolId, raw: &[String]) -> bool {
721    let raw = raw
722        .iter()
723        .filter(|part| !matches!(part.as_str(), "crate" | "self" | "super"))
724        .map(String::as_str)
725        .collect::<Vec<_>>();
726    let candidate = candidate.qualified_parts();
727    raw.len() <= candidate.len() && candidate[candidate.len() - raw.len()..] == raw
728}