1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::atomic::{AtomicUsize, Ordering};
4use std::sync::{Arc, Mutex, OnceLock};
5
6use rayon::prelude::*;
7use serde::{Deserialize, Serialize};
8
9use super::deep_queries;
10use super::graph_provider::GraphProvider;
11use super::index_paths::normalize_project_root;
12
13const CALL_GRAPH_FORMAT: u32 = 2;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct CallGraph {
25 #[serde(default)]
27 pub format_version: u32,
28 pub project_root: String,
29 pub edges: Vec<CallEdge>,
30 pub file_hashes: HashMap<String, String>,
31 #[serde(default)]
35 pub file_stamps: HashMap<String, FileStamp>,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub skipped_files: Vec<String>,
40}
41
42pub type FileStamp = (u64, u64);
44
45type FileResult = (String, Option<FileStamp>, Option<(String, Vec<CallEdge>)>);
48
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct CallEdge {
51 pub caller_file: String,
52 pub caller_symbol: String,
53 pub caller_line: usize,
55 pub callee_name: String,
56 #[serde(default)]
59 pub callee_pos: Option<(usize, usize)>,
60 #[serde(default)]
61 pub receiver: Option<String>,
62 #[serde(default)]
63 pub is_method: bool,
64}
65
66fn edges_for_file(
69 rel_path: &str,
70 calls: &[deep_queries::CallSite],
71 file_symbols: Option<&Vec<SymbolSpan>>,
72) -> Vec<CallEdge> {
73 calls
74 .iter()
75 .map(|call| CallEdge {
76 caller_file: rel_path.to_string(),
77 caller_symbol: find_enclosing_symbol_owned(file_symbols, call.line),
78 caller_line: call.line,
79 callee_name: call.callee.clone(),
80 callee_pos: call.callee_pos,
81 receiver: call.receiver.clone(),
82 is_method: call.is_method,
83 })
84 .collect()
85}
86
87#[derive(Debug, Clone, Default)]
90pub struct SymbolSpan {
91 pub file: String,
92 pub name: String,
93 pub start_line: usize,
94 pub end_line: usize,
95 pub kind: String,
97}
98
99#[derive(Debug, Clone, Default)]
105pub struct CallGraphInputs {
106 pub project_root: String,
107 pub file_paths: Vec<String>,
108 pub symbols: Vec<SymbolSpan>,
109 pub import_edges: Vec<(String, String)>,
111}
112
113impl CallGraphInputs {
114 pub fn open(project_root: &str) -> Self {
119 match crate::core::graph_provider::open_or_build(project_root) {
120 Some(open) => Self::from_provider(project_root, &open.provider),
121 None => Self {
122 project_root: normalize_project_root(project_root),
123 ..Default::default()
124 },
125 }
126 }
127
128 pub fn from_project_index(index: &super::graph_index::ProjectIndex) -> Self {
134 let symbols = index
135 .symbols
136 .values()
137 .map(|s| SymbolSpan {
138 file: s.file.clone(),
139 name: s.name.clone(),
140 start_line: s.start_line,
141 end_line: s.end_line,
142 kind: s.kind.clone(),
143 })
144 .collect();
145 let import_edges = index
146 .edges
147 .iter()
148 .filter(|e| e.kind == "import" || e.kind == "reexport")
149 .map(|e| (e.from.clone(), e.to.clone()))
150 .collect();
151 Self {
152 project_root: index.project_root.clone(),
153 file_paths: index.files.keys().cloned().collect(),
154 symbols,
155 import_edges,
156 }
157 }
158
159 pub fn from_provider(project_root: &str, provider: &GraphProvider) -> Self {
161 let symbols = provider
162 .all_symbols()
163 .into_iter()
164 .map(|s| SymbolSpan {
165 file: s.file,
166 name: s.name,
167 start_line: s.start_line,
168 end_line: s.end_line,
169 kind: s.kind,
170 })
171 .collect();
172 let import_edges = provider
173 .edges()
174 .into_iter()
175 .filter(|e| e.kind == "import" || e.kind == "reexport")
176 .map(|e| (e.from, e.to))
177 .collect();
178 Self {
179 project_root: normalize_project_root(project_root),
180 file_paths: provider.file_paths(),
181 symbols,
182 import_edges,
183 }
184 }
185}
186
187#[derive(Debug, Clone)]
188pub struct BfsNode {
189 pub symbol: String,
190 pub file: String,
191 pub line: usize,
192 pub depth: usize,
193 pub from_symbol: String,
194}
195
196#[derive(Debug, Clone)]
197pub struct PathHop {
198 pub symbol: String,
199 pub file: String,
200 pub line: usize,
201}
202
203#[derive(Clone, Copy)]
204enum BfsDirection {
205 Callers,
206 Callees,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum RiskLevel {
211 Low,
212 Medium,
213 High,
214 Critical,
215}
216
217impl RiskLevel {
218 pub fn from_caller_count(count: usize) -> Self {
219 match count {
220 0..=1 => Self::Low,
221 2..=4 => Self::Medium,
222 5..=10 => Self::High,
223 _ => Self::Critical,
224 }
225 }
226
227 pub fn label(self) -> &'static str {
228 match self {
229 Self::Low => "LOW",
230 Self::Medium => "MEDIUM",
231 Self::High => "HIGH",
232 Self::Critical => "CRITICAL",
233 }
234 }
235}
236
237#[derive(Debug, Clone, Serialize)]
242pub struct BuildProgress {
243 pub status: &'static str,
244 pub files_total: usize,
245 pub files_done: usize,
246 pub edges_found: usize,
247}
248
249enum BuildState {
250 Idle,
251 Building {
252 files_total: usize,
253 files_done: Arc<AtomicUsize>,
254 edges_found: Arc<AtomicUsize>,
255 },
256 Ready(Arc<CallGraph>),
257 Failed(String),
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
267struct BuildKey {
268 root: String,
269 protected: bool,
270}
271
272impl BuildKey {
273 fn for_request(project_root: &str) -> Self {
274 Self {
275 root: normalize_project_root(project_root),
276 protected: crate::core::context_admission::stored::source_authority_required(),
277 }
278 }
279}
280
281const MAX_RESIDENT_BUILDS: usize = 4;
284
285static IDLE: BuildState = BuildState::Idle;
287
288#[derive(Default)]
290struct Builds {
291 entries: Vec<(BuildKey, BuildState)>,
292}
293
294impl Builds {
295 fn state(&self, key: &BuildKey) -> &BuildState {
296 self.entries
297 .iter()
298 .find(|(k, _)| k == key)
299 .map_or(&IDLE, |(_, state)| state)
300 }
301
302 fn latest(&self) -> &BuildState {
304 self.entries.last().map_or(&IDLE, |(_, state)| state)
305 }
306
307 fn set(&mut self, key: BuildKey, state: BuildState) {
308 self.entries.retain(|(k, _)| *k != key);
309 if self.entries.len() >= MAX_RESIDENT_BUILDS {
310 let evict = self
311 .entries
312 .iter()
313 .position(|(_, state)| !matches!(state, BuildState::Building { .. }))
314 .unwrap_or(0);
315 self.entries.remove(evict);
316 }
317 self.entries.push((key, state));
318 }
319}
320
321static BUILD: OnceLock<Mutex<Builds>> = OnceLock::new();
322
323fn global_state() -> &'static Mutex<Builds> {
324 BUILD.get_or_init(|| Mutex::new(Builds::default()))
325}
326
327impl CallGraph {
328 pub fn new(project_root: &str) -> Self {
329 Self {
330 format_version: CALL_GRAPH_FORMAT,
331 project_root: normalize_project_root(project_root),
332 edges: Vec::new(),
333 file_hashes: HashMap::new(),
334 file_stamps: HashMap::new(),
335 skipped_files: Vec::new(),
336 }
337 }
338
339 pub fn build_parallel(
344 inputs: &CallGraphInputs,
345 progress: Option<(&AtomicUsize, &AtomicUsize)>,
346 ) -> Self {
347 let project_root = &inputs.project_root;
348 let symbols_by_file = group_symbols_by_file_owned(inputs);
349 let file_keys: Vec<String> = inputs.file_paths.clone();
350
351 let results: Vec<FileResult> = file_keys
352 .par_iter()
353 .filter_map(|rel_path| {
354 let abs_path = resolve_path(rel_path, project_root);
355 let stamp = file_stamp(&abs_path);
358 let Ok(content) = read_source(&abs_path)? else {
359 return Some((rel_path.clone(), stamp, None));
360 };
361 let hash = simple_hash(&content);
362
363 let ext = Path::new(rel_path)
364 .extension()
365 .and_then(|e| e.to_str())
366 .unwrap_or("");
367
368 let analysis = deep_queries::analyze(&content, ext);
369 let file_symbols = symbols_by_file.get(rel_path.as_str());
370
371 let edges = edges_for_file(rel_path, &analysis.calls, file_symbols);
372
373 if let Some((done, edge_count)) = progress {
374 done.fetch_add(1, Ordering::Relaxed);
375 edge_count.fetch_add(edges.len(), Ordering::Relaxed);
376 }
377
378 Some((rel_path.clone(), stamp, Some((hash, edges))))
379 })
380 .collect();
381
382 Self::from_results(project_root, results)
383 }
384
385 fn from_results(project_root: &str, results: Vec<FileResult>) -> Self {
387 let mut graph = Self::new(project_root);
388 let edge_capacity: usize = results
389 .iter()
390 .filter_map(|(_, _, r)| r.as_ref().map(|(_, e)| e.len()))
391 .sum();
392 graph.edges.reserve(edge_capacity);
393 graph.file_hashes.reserve(results.len());
394 graph.file_stamps.reserve(results.len());
395
396 for (path, stamp, result) in results {
397 if let Some(stamp) = stamp {
398 graph.file_stamps.insert(path.clone(), stamp);
399 }
400 match result {
401 Some((hash, edges)) => {
402 graph.file_hashes.insert(path, hash);
403 graph.edges.extend(edges);
404 }
405 None => graph.skipped_files.push(path),
406 }
407 }
408 graph.skipped_files.sort();
409
410 graph
411 }
412
413 pub fn build_incremental_parallel(
418 inputs: &CallGraphInputs,
419 previous: &CallGraph,
420 progress: Option<(&AtomicUsize, &AtomicUsize)>,
421 ) -> Self {
422 let project_root = &inputs.project_root;
423 let symbols_by_file = group_symbols_by_file_owned(inputs);
424 let file_keys: Vec<String> = inputs.file_paths.clone();
425
426 let prev_edges_by_file = group_edges_by_file(&previous.edges);
427
428 let results: Vec<FileResult> = file_keys
429 .par_iter()
430 .filter_map(|rel_path| {
431 let abs_path = resolve_path(rel_path, project_root);
432 let stamp = file_stamp(&abs_path);
433 let Ok(content) = read_source(&abs_path)? else {
434 return Some((rel_path.clone(), stamp, None));
435 };
436 let hash = simple_hash(&content);
437 let changed = previous.file_hashes.get(rel_path.as_str()) != Some(&hash);
438
439 let edges = if changed {
440 let ext = Path::new(rel_path)
441 .extension()
442 .and_then(|e| e.to_str())
443 .unwrap_or("");
444
445 let analysis = deep_queries::analyze(&content, ext);
446 let file_symbols = symbols_by_file.get(rel_path.as_str());
447
448 edges_for_file(rel_path, &analysis.calls, file_symbols)
449 } else {
450 prev_edges_by_file
451 .get(rel_path.as_str())
452 .cloned()
453 .unwrap_or_default()
454 };
455
456 if let Some((done, edge_count)) = progress {
457 done.fetch_add(1, Ordering::Relaxed);
458 edge_count.fetch_add(edges.len(), Ordering::Relaxed);
459 }
460
461 Some((rel_path.clone(), stamp, Some((hash, edges))))
462 })
463 .collect();
464
465 Self::from_results(project_root, results)
466 }
467
468 pub fn get_or_start_build(
474 project_root: &str,
475 inputs: Arc<CallGraphInputs>,
476 ) -> Result<Arc<CallGraph>, BuildProgress> {
477 let key = BuildKey::for_request(project_root);
478 let state = global_state();
479 let mut guard = state
480 .lock()
481 .unwrap_or_else(std::sync::PoisonError::into_inner);
482
483 match guard.state(&key) {
484 BuildState::Ready(graph) if !holds_withdrawn_files(graph, &inputs) => {
491 return Ok(Arc::clone(graph));
492 }
493 BuildState::Ready(_) | BuildState::Idle => {}
494 BuildState::Building {
495 files_total,
496 files_done,
497 edges_found,
498 } => {
499 return Err(BuildProgress {
500 status: "building",
501 files_total: *files_total,
502 files_done: files_done.load(Ordering::Relaxed),
503 edges_found: edges_found.load(Ordering::Relaxed),
504 });
505 }
506 BuildState::Failed(msg) => {
507 tracing::warn!("[call_graph: previous build failed: {msg} — retrying]");
508 }
509 }
510
511 if let Some(cached) = Self::load(project_root)
514 && !cache_looks_stale(&cached, &inputs)
515 {
516 let arc = Arc::new(cached);
517 guard.set(key, BuildState::Ready(Arc::clone(&arc)));
518 return Ok(arc);
519 }
520
521 let files_total = inputs.file_paths.len();
522 let files_done = Arc::new(AtomicUsize::new(0));
523 let edges_found = Arc::new(AtomicUsize::new(0));
524
525 guard.set(
526 key.clone(),
527 BuildState::Building {
528 files_total,
529 files_done: Arc::clone(&files_done),
530 edges_found: Arc::clone(&edges_found),
531 },
532 );
533 drop(guard);
534
535 let root = key.root.clone();
536 let fd = Arc::clone(&files_done);
537 let ef = Arc::clone(&edges_found);
538
539 std::thread::spawn(move || {
540 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
541 let previous = CallGraph::load(&root);
542 if let Some(prev) = &previous {
543 CallGraph::build_incremental_parallel(&inputs, prev, Some((&fd, &ef)))
544 } else {
545 CallGraph::build_parallel(&inputs, Some((&fd, &ef)))
546 }
547 }));
548
549 match result {
550 Ok(graph) => {
551 let _ = graph.save();
552 let arc = Arc::new(graph);
553 if let Ok(mut g) = global_state().lock() {
554 g.set(key, BuildState::Ready(Arc::clone(&arc)));
555 }
556 tracing::info!(
557 "[call_graph: build complete — {} files, {} edges]",
558 arc.file_hashes.len(),
559 arc.edges.len()
560 );
561 }
562 Err(e) => {
563 let msg = format!("{e:?}");
564 tracing::error!("[call_graph: build panicked: {msg}]");
565 if let Ok(mut g) = global_state().lock() {
566 g.set(key, BuildState::Failed(msg));
567 }
568 }
569 }
570 });
571
572 Err(BuildProgress {
573 status: "building",
574 files_total,
575 files_done: 0,
576 edges_found: 0,
577 })
578 }
579
580 pub fn build_status() -> BuildProgress {
583 let state = global_state();
584 let guard = state
585 .lock()
586 .unwrap_or_else(std::sync::PoisonError::into_inner);
587 match guard.latest() {
588 BuildState::Idle => BuildProgress {
589 status: "idle",
590 files_total: 0,
591 files_done: 0,
592 edges_found: 0,
593 },
594 BuildState::Building {
595 files_total,
596 files_done,
597 edges_found,
598 } => BuildProgress {
599 status: "building",
600 files_total: *files_total,
601 files_done: files_done.load(Ordering::Relaxed),
602 edges_found: edges_found.load(Ordering::Relaxed),
603 },
604 BuildState::Ready(graph) => BuildProgress {
605 status: "ready",
606 files_total: graph.file_hashes.len(),
607 files_done: graph.file_hashes.len(),
608 edges_found: graph.edges.len(),
609 },
610 BuildState::Failed(msg) => {
611 tracing::debug!("[call_graph: status check — failed: {msg}]");
612 BuildProgress {
613 status: "error",
614 files_total: 0,
615 files_done: 0,
616 edges_found: 0,
617 }
618 }
619 }
620 }
621
622 pub fn invalidate(project_root: &str) {
627 let root = normalize_project_root(project_root);
628 if let Ok(mut g) = global_state().lock() {
629 g.entries.retain(|(key, state)| {
630 key.root != root || matches!(state, BuildState::Building { .. })
631 });
632 }
633 }
634
635 pub fn build(inputs: &CallGraphInputs) -> Self {
640 Self::build_parallel(inputs, None)
641 }
642
643 pub fn build_incremental(inputs: &CallGraphInputs, previous: &CallGraph) -> Self {
644 Self::build_incremental_parallel(inputs, previous, None)
645 }
646
647 pub fn callers_of(&self, symbol: &str) -> Vec<&CallEdge> {
650 self.edges
651 .iter()
652 .filter(|e| callee_matches(&e.callee_name, symbol))
653 .collect()
654 }
655
656 pub fn callees_of(&self, symbol: &str) -> Vec<&CallEdge> {
657 let sym_lower = symbol.to_lowercase();
658 self.edges
659 .iter()
660 .filter(|e| e.caller_symbol.to_lowercase() == sym_lower)
661 .collect()
662 }
663
664 pub fn bfs_callers(&self, symbol: &str, max_depth: usize) -> Vec<BfsNode> {
670 self.bfs_traverse(symbol, max_depth, BfsDirection::Callers)
671 }
672
673 pub fn bfs_callees(&self, symbol: &str, max_depth: usize) -> Vec<BfsNode> {
675 self.bfs_traverse(symbol, max_depth, BfsDirection::Callees)
676 }
677
678 fn bfs_traverse(&self, symbol: &str, max_depth: usize, dir: BfsDirection) -> Vec<BfsNode> {
679 use std::collections::{HashSet, VecDeque};
680
681 let mut visited: HashSet<String> = HashSet::new();
682 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
683 let mut result: Vec<BfsNode> = Vec::new();
684
685 let start = symbol.to_lowercase();
686 visited.insert(start.clone());
687 queue.push_back((start, 0));
688
689 while let Some((current, depth)) = queue.pop_front() {
690 if depth >= max_depth {
691 continue;
692 }
693
694 let neighbors: Vec<&CallEdge> = match dir {
695 BfsDirection::Callers => self
696 .edges
697 .iter()
698 .filter(|e| callee_matches(&e.callee_name, ¤t))
699 .collect(),
700 BfsDirection::Callees => self
701 .edges
702 .iter()
703 .filter(|e| e.caller_symbol.to_lowercase() == callee_segment(¤t))
704 .collect(),
705 };
706
707 for edge in neighbors {
708 let next_sym = match dir {
709 BfsDirection::Callers => &edge.caller_symbol,
710 BfsDirection::Callees => &edge.callee_name,
711 };
712 let next_lower = callee_segment(next_sym).to_lowercase();
715
716 if !visited.insert(next_lower.clone()) {
717 continue;
718 }
719
720 result.push(BfsNode {
721 symbol: next_sym.clone(),
722 file: edge.caller_file.clone(),
723 line: edge.caller_line,
724 depth: depth + 1,
725 from_symbol: if depth == 0 {
726 symbol.to_string()
727 } else {
728 current.clone()
729 },
730 });
731
732 queue.push_back((next_lower, depth + 1));
733 }
734 }
735
736 result
737 }
738
739 pub fn find_call_path(&self, from: &str, to: &str) -> Option<Vec<PathHop>> {
744 use std::collections::{HashMap as BfsMap, VecDeque};
745
746 let from_lower = callee_segment(from).to_lowercase();
749 let to_lower = callee_segment(to).to_lowercase();
750
751 if from_lower == to_lower {
752 return Some(vec![PathHop {
753 symbol: from.to_string(),
754 file: String::new(),
755 line: 0,
756 }]);
757 }
758
759 const MAX_TRACE_DEPTH: usize = 10;
760
761 let mut visited: BfsMap<String, (String, String, usize, usize)> = BfsMap::new();
763 let mut queue: VecDeque<String> = VecDeque::new();
764
765 visited.insert(from_lower.clone(), (String::new(), String::new(), 0, 0));
766 queue.push_back(from_lower.clone());
767
768 while let Some(current) = queue.pop_front() {
769 let current_depth = visited.get(¤t).map_or(0, |e| e.3);
770 if current_depth >= MAX_TRACE_DEPTH {
771 continue;
772 }
773
774 let callees: Vec<&CallEdge> = self
775 .edges
776 .iter()
777 .filter(|e| e.caller_symbol.to_lowercase() == current)
778 .collect();
779
780 for edge in callees {
781 let next = callee_segment(&edge.callee_name).to_lowercase();
782 if visited.contains_key(&next) {
783 continue;
784 }
785
786 visited.insert(
787 next.clone(),
788 (
789 current.clone(),
790 edge.caller_file.clone(),
791 edge.caller_line,
792 current_depth + 1,
793 ),
794 );
795
796 if next == to_lower {
797 return Some(Self::reconstruct_path(
798 &visited,
799 &from_lower,
800 &to_lower,
801 from,
802 to,
803 ));
804 }
805
806 queue.push_back(next);
807 }
808 }
809
810 None
811 }
812
813 fn reconstruct_path(
814 visited: &std::collections::HashMap<String, (String, String, usize, usize)>,
815 from_lower: &str,
816 to_lower: &str,
817 from_orig: &str,
818 to_orig: &str,
819 ) -> Vec<PathHop> {
820 let mut path = Vec::new();
821 let mut current = to_lower.to_string();
822
823 while current != from_lower {
824 let (parent, file, line, _depth) = &visited[¤t];
825 let sym_name = if current == to_lower {
826 to_orig.to_string()
827 } else {
828 current.clone()
829 };
830 path.push(PathHop {
831 symbol: sym_name,
832 file: file.clone(),
833 line: *line,
834 });
835 current = parent.clone();
836 }
837
838 path.push(PathHop {
839 symbol: from_orig.to_string(),
840 file: String::new(),
841 line: 0,
842 });
843
844 path.reverse();
845 path
846 }
847
848 pub fn transitive_caller_count(&self, symbol: &str, max_depth: usize) -> usize {
850 let nodes = self.bfs_callers(symbol, max_depth);
851 let mut unique: std::collections::HashSet<String> = std::collections::HashSet::new();
852 for node in &nodes {
853 unique.insert(node.symbol.to_lowercase());
854 }
855 unique.len()
856 }
857
858 pub fn save(&self) -> Result<(), String> {
859 let dir = call_graph_dir(&self.project_root)
860 .ok_or_else(|| "Cannot determine home directory".to_string())?;
861 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
862 let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
863 let compressed = zstd::encode_all(json.as_bytes(), 9).map_err(|e| format!("zstd: {e}"))?;
864 let target = dir.join("call_graph.json.zst");
865 let tmp = target.with_extension("zst.tmp");
866 std::fs::write(&tmp, &compressed).map_err(|e| e.to_string())?;
867 std::fs::rename(&tmp, &target).map_err(|e| e.to_string())?;
868 let _ = std::fs::remove_file(dir.join("call_graph.json"));
869 Ok(())
870 }
871
872 pub fn load(project_root: &str) -> Option<Self> {
873 Self::load_any_version(project_root).filter(|g| g.format_version == CALL_GRAPH_FORMAT)
874 }
875
876 fn load_any_version(project_root: &str) -> Option<Self> {
877 let dir = call_graph_dir(project_root)?;
878
879 let zst_path = dir.join("call_graph.json.zst");
880 if zst_path.exists() {
881 let compressed = std::fs::read(&zst_path).ok()?;
882 let data = zstd::decode_all(compressed.as_slice()).ok()?;
883 let content = String::from_utf8(data).ok()?;
884 return serde_json::from_str(&content).ok();
885 }
886
887 let json_path = dir.join("call_graph.json");
888 if json_path.exists() {
889 let content = std::fs::read_to_string(&json_path).ok()?;
890 let parsed: Self = serde_json::from_str(&content).ok()?;
891 if let Ok(compressed) = zstd::encode_all(content.as_bytes(), 9) {
893 let zst_tmp = zst_path.with_extension("zst.tmp");
894 if std::fs::write(&zst_tmp, &compressed).is_ok()
895 && std::fs::rename(&zst_tmp, &zst_path).is_ok()
896 {
897 let _ = std::fs::remove_file(&json_path);
898 }
899 }
900 return Some(parsed);
901 }
902
903 None
904 }
905
906 pub fn load_or_build(project_root: &str, inputs: &CallGraphInputs) -> Self {
907 if let Some(previous) = Self::load(project_root) {
908 Self::build_incremental(inputs, &previous)
909 } else {
910 Self::build(inputs)
911 }
912 }
913}
914
915fn cache_looks_stale(cached: &CallGraph, inputs: &CallGraphInputs) -> bool {
920 if cached.file_hashes.len() + cached.skipped_files.len() != inputs.file_paths.len() {
921 return true;
922 }
923 let cached_files: std::collections::HashSet<&str> = cached
924 .file_hashes
925 .keys()
926 .chain(&cached.skipped_files)
927 .map(String::as_str)
928 .collect();
929 let index_files: std::collections::HashSet<&str> =
930 inputs.file_paths.iter().map(String::as_str).collect();
931 if cached_files != index_files {
932 return true;
933 }
934 inputs.file_paths.iter().any(|rel| {
936 let stamp = file_stamp(&resolve_path(rel, &inputs.project_root));
937 stamp.is_none() || cached.file_stamps.get(rel) != stamp.as_ref()
938 })
939}
940
941fn file_stamp(path: &str) -> Option<FileStamp> {
943 let meta = std::fs::metadata(path).ok()?;
944 let mtime = meta
945 .modified()
946 .ok()?
947 .duration_since(std::time::UNIX_EPOCH)
948 .ok()?;
949 Some((
950 meta.len(),
951 u64::try_from(mtime.as_nanos()).unwrap_or(u64::MAX),
952 ))
953}
954
955fn holds_withdrawn_files(cached: &CallGraph, inputs: &CallGraphInputs) -> bool {
960 let current: std::collections::HashSet<&str> =
961 inputs.file_paths.iter().map(String::as_str).collect();
962 cached
963 .file_hashes
964 .keys()
965 .any(|file| !current.contains(file.as_str()))
966}
967
968fn call_graph_dir(project_root: &str) -> Option<std::path::PathBuf> {
973 GraphProvider::index_dir(project_root)
974}
975
976fn group_edges_by_file(edges: &[CallEdge]) -> HashMap<&str, Vec<CallEdge>> {
977 let mut map: HashMap<&str, Vec<CallEdge>> = HashMap::new();
978 for edge in edges {
979 map.entry(edge.caller_file.as_str())
980 .or_default()
981 .push(edge.clone());
982 }
983 map
984}
985
986fn group_symbols_by_file_owned(inputs: &CallGraphInputs) -> HashMap<String, Vec<SymbolSpan>> {
988 let mut map: HashMap<String, Vec<SymbolSpan>> = HashMap::new();
989 for sym in &inputs.symbols {
990 map.entry(sym.file.clone()).or_default().push(sym.clone());
991 }
992 for syms in map.values_mut() {
993 syms.sort_by_key(|s| s.start_line);
994 }
995 map
996}
997
998fn find_enclosing_symbol_owned(file_symbols: Option<&Vec<SymbolSpan>>, line: usize) -> String {
999 let Some(syms) = file_symbols else {
1000 return "<module>".to_string();
1001 };
1002 let mut best: Option<&SymbolSpan> = None;
1003 for sym in syms {
1004 if line >= sym.start_line && line <= sym.end_line {
1005 match best {
1006 None => best = Some(sym),
1007 Some(prev) => {
1008 if (sym.end_line - sym.start_line) < (prev.end_line - prev.start_line) {
1009 best = Some(sym);
1010 }
1011 }
1012 }
1013 }
1014 }
1015 best.map_or_else(|| "<module>".to_string(), |s| s.name.clone())
1016}
1017
1018fn resolve_path(relative: &str, project_root: &str) -> String {
1019 let p = Path::new(relative);
1020 if p.is_absolute() && p.exists() {
1021 return relative.to_string();
1022 }
1023 let relative = relative.trim_start_matches(['/', '\\']);
1024 let joined = Path::new(project_root).join(relative);
1025 joined.to_string_lossy().to_string()
1026}
1027
1028fn read_source(abs_path: &str) -> Option<Result<String, std::string::FromUtf8Error>> {
1031 std::fs::read(abs_path).ok().map(String::from_utf8)
1032}
1033
1034fn simple_hash(content: &str) -> String {
1035 use std::hash::{Hash, Hasher};
1036 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1037 content.hash(&mut hasher);
1038 format!("{:x}", hasher.finish())
1039}
1040
1041pub fn current_file_hashes(inputs: &CallGraphInputs) -> HashMap<String, String> {
1046 inputs
1047 .file_paths
1048 .par_iter()
1049 .filter_map(|rel| {
1050 let content = std::fs::read_to_string(resolve_path(rel, &inputs.project_root)).ok()?;
1051 Some((rel.clone(), simple_hash(&content)))
1052 })
1053 .collect()
1054}
1055
1056pub fn build_import_adjacency(
1068 inputs: &CallGraphInputs,
1069) -> HashMap<String, std::collections::HashSet<String>> {
1070 let mut adj: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
1071 for (from, to) in &inputs.import_edges {
1072 adj.entry(from.clone()).or_default().insert(to.clone());
1073 }
1074 adj
1075}
1076
1077fn rank_callee_def_file(
1085 def_files: &[&str],
1086 caller_file: &str,
1087 imports: &HashMap<String, std::collections::HashSet<String>>,
1088) -> Option<(String, ScopeMatch)> {
1089 if def_files.is_empty() {
1090 return None;
1091 }
1092 if def_files.contains(&caller_file) {
1093 return Some((caller_file.to_string(), ScopeMatch::SameFile));
1094 }
1095 if let Some(imported) = imports.get(caller_file) {
1096 let mut in_scope = def_files.iter().filter(|f| imported.contains(**f));
1097 if let Some(first) = in_scope.next()
1098 && in_scope.next().is_none()
1099 {
1100 return Some(((*first).to_string(), ScopeMatch::UniqueImport));
1101 }
1102 }
1103 if def_files.len() == 1 {
1104 return Some((def_files[0].to_string(), ScopeMatch::UniqueInProject));
1105 }
1106 None
1107}
1108
1109pub fn callee_segment(name: &str) -> &str {
1112 name.rsplit(|c: char| c == ':' || c == '.')
1113 .next()
1114 .unwrap_or(name)
1115}
1116
1117fn path_segments(name: &str) -> Vec<String> {
1121 name.split(|c: char| c == ':' || c == '.')
1122 .filter(|s| !s.is_empty())
1123 .map(str::to_lowercase)
1124 .filter(|s| !matches!(s.as_str(), "crate" | "self" | "super"))
1125 .collect()
1126}
1127
1128pub fn callee_matches(callee_name: &str, query: &str) -> bool {
1136 let callee = path_segments(callee_name);
1137 let query = path_segments(query);
1138 if callee.is_empty() || query.is_empty() {
1139 return false;
1140 }
1141 let (short, long) = if callee.len() <= query.len() {
1142 (&callee, &query)
1143 } else {
1144 (&query, &callee)
1145 };
1146 long.ends_with(short)
1147}
1148
1149fn module_path_def_file<'a>(
1155 callee_name: &str,
1156 defs: &[&'a str],
1157 type_files: &HashMap<&str, Vec<&str>>,
1158) -> Option<&'a str> {
1159 let segments: Vec<&str> = callee_name
1160 .split(|c: char| c == ':' || c == '.')
1161 .filter(|s| !s.is_empty() && !matches!(*s, "crate" | "self" | "super" | "Self"))
1162 .collect();
1163 let qualifier = *segments
1164 .len()
1165 .checked_sub(2)
1166 .and_then(|i| segments.get(i))?;
1167 let names_module = |file: &str| -> bool {
1168 let path = Path::new(file);
1169 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
1170 if stem == qualifier {
1171 return true;
1172 }
1173 matches!(stem, "mod" | "index" | "__init__" | "lib")
1174 && path
1175 .parent()
1176 .and_then(|p| p.file_name())
1177 .and_then(|n| n.to_str())
1178 == Some(qualifier)
1179 };
1180 let type_defs = type_files.get(qualifier);
1181 let mut named = defs
1182 .iter()
1183 .copied()
1184 .filter(|d| names_module(d) || type_defs.is_some_and(|files| files.contains(d)));
1185 let first = named.next()?;
1186 named.next().is_none().then_some(first)
1187}
1188
1189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1194pub enum ScopeMatch {
1195 SameFile,
1196 UniqueImport,
1197 UniqueInProject,
1198 ModulePath,
1202}
1203
1204#[derive(Debug, Clone, PartialEq, Eq)]
1206pub enum StructuralTarget {
1207 Resolved {
1208 file: String,
1209 via: ScopeMatch,
1210 },
1211 Ambiguous,
1213 Unknown,
1215}
1216
1217impl StructuralTarget {
1218 pub fn file(&self) -> Option<&str> {
1219 match self {
1220 Self::Resolved { file, .. } => Some(file),
1221 Self::Ambiguous | Self::Unknown => None,
1222 }
1223 }
1224}
1225
1226pub fn resolve_callee_file(
1228 callee_name: &str,
1229 caller_file: &str,
1230 inputs: &CallGraphInputs,
1231 imports: &HashMap<String, std::collections::HashSet<String>>,
1232) -> Option<String> {
1233 let mut def_files: Vec<&str> = inputs
1234 .symbols
1235 .iter()
1236 .filter(|s| s.name == callee_name)
1237 .map(|s| s.file.as_str())
1238 .collect();
1239 def_files.sort_unstable();
1240 def_files.dedup();
1241 rank_callee_def_file(&def_files, caller_file, imports).map(|(file, _)| file)
1242}
1243
1244pub fn resolve_edge_callee_files(
1249 inputs: &CallGraphInputs,
1250 edges: &[CallEdge],
1251) -> Vec<Option<String>> {
1252 resolve_edge_callee_targets(inputs, edges)
1253 .into_iter()
1254 .map(|t| t.file().map(str::to_string))
1255 .collect()
1256}
1257
1258pub fn resolve_edge_callee_targets(
1262 inputs: &CallGraphInputs,
1263 edges: &[CallEdge],
1264) -> Vec<StructuralTarget> {
1265 let callee_names: std::collections::HashSet<&str> = edges
1266 .iter()
1267 .flat_map(|e| [e.callee_name.as_str(), callee_segment(&e.callee_name)])
1268 .collect();
1269 if callee_names.is_empty() {
1270 return Vec::new();
1271 }
1272
1273 let mut name_files: HashMap<&str, Vec<&str>> = HashMap::new();
1276 let mut type_files: HashMap<&str, Vec<&str>> = HashMap::new();
1277 for sym in &inputs.symbols {
1278 if callee_names.contains(sym.name.as_str()) {
1279 name_files
1280 .entry(sym.name.as_str())
1281 .or_default()
1282 .push(sym.file.as_str());
1283 }
1284 if sym.name.starts_with(char::is_uppercase) {
1285 type_files
1286 .entry(sym.name.as_str())
1287 .or_default()
1288 .push(sym.file.as_str());
1289 }
1290 }
1291 for files in name_files.values_mut().chain(type_files.values_mut()) {
1292 files.sort_unstable();
1293 files.dedup();
1294 }
1295
1296 let imports = build_import_adjacency(inputs);
1297 edges
1298 .iter()
1299 .map(|e| {
1300 let family = call_family(&e.caller_file);
1303 let segment = callee_segment(&e.callee_name);
1304 if segment.len() < e.callee_name.len() {
1305 let Some(defs) = name_files
1309 .get(segment)
1310 .map(|defs| same_family_defs(defs, family))
1311 .filter(|d| !d.is_empty())
1312 else {
1313 return StructuralTarget::Unknown;
1314 };
1315 let own_scope = ["Self::", "self::"]
1318 .iter()
1319 .any(|p| e.callee_name.starts_with(p));
1320 if own_scope && defs.contains(&e.caller_file.as_str()) {
1321 return StructuralTarget::Resolved {
1322 file: e.caller_file.clone(),
1323 via: ScopeMatch::SameFile,
1324 };
1325 }
1326 return match module_path_def_file(&e.callee_name, &defs, &type_files) {
1327 Some(file) => StructuralTarget::Resolved {
1328 file: file.to_string(),
1329 via: ScopeMatch::ModulePath,
1330 },
1331 None => StructuralTarget::Ambiguous,
1332 };
1333 }
1334 let defs = name_files
1335 .get(e.callee_name.as_str())
1336 .map(|defs| same_family_defs(defs, family))
1337 .filter(|d| !d.is_empty());
1338 match defs {
1339 None => StructuralTarget::Unknown,
1340 Some(defs) => match rank_callee_def_file(&defs, &e.caller_file, &imports) {
1341 Some((file, via)) => StructuralTarget::Resolved { file, via },
1342 None => StructuralTarget::Ambiguous,
1343 },
1344 }
1345 })
1346 .collect()
1347}
1348
1349fn same_family_defs<'a>(defs: &[&'a str], family: Option<&str>) -> Vec<&'a str> {
1352 defs.iter()
1353 .copied()
1354 .filter(|d| family.is_none() || call_family(d).is_none_or(|f| Some(f) == family))
1355 .collect()
1356}
1357
1358fn call_family(path: &str) -> Option<&'static str> {
1361 let id = crate::core::language_capabilities::language_for_path(path)?.id_str();
1362 Some(match id {
1363 "typescript" | "javascript" | "vue" | "svelte" => "js",
1364 "c" | "cpp" => "c",
1365 "java" | "kotlin" | "scala" => "jvm",
1366 "lua" | "luau" => "lua",
1367 other => other,
1368 })
1369}
1370
1371pub fn resolve_callee_files(
1376 inputs: &CallGraphInputs,
1377 edges: &[CallEdge],
1378) -> HashMap<String, String> {
1379 use std::collections::HashSet;
1380
1381 let mut resolved: HashMap<&str, HashSet<String>> = HashMap::new();
1382 for (e, file) in edges.iter().zip(resolve_edge_callee_files(inputs, edges)) {
1383 if let Some(file) = file {
1384 resolved
1385 .entry(e.callee_name.as_str())
1386 .or_default()
1387 .insert(file);
1388 }
1389 }
1390
1391 resolved
1392 .into_iter()
1393 .filter_map(|(name, files)| {
1394 (files.len() == 1).then(|| {
1395 (
1396 name.to_string(),
1397 files
1398 .into_iter()
1399 .next()
1400 .expect("files.len() == 1 guarantees one element"),
1401 )
1402 })
1403 })
1404 .collect()
1405}
1406
1407#[cfg(test)]
1408#[path = "call_graph_tests.rs"]
1409pub mod tests;