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 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 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 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 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 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}