1use crate::project::ProjectRoot;
2use anyhow::Result;
3use regex::Regex;
4use serde::Serialize;
5use std::collections::{HashMap, HashSet};
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, LazyLock, Mutex};
9use streaming_iterator::StreamingIterator;
10use tree_sitter::{Language, Parser, Query, QueryCursor};
11
12use crate::import_graph::GraphCache;
13
14type CallQueryCacheKey = (&'static str, usize);
17type CallQueryCache = Mutex<HashMap<CallQueryCacheKey, Arc<Query>>>;
18
19static CALL_QUERY_CACHE: LazyLock<CallQueryCache> = LazyLock::new(|| Mutex::new(HashMap::new()));
20static JS_IMPORT_FROM_RE: LazyLock<Regex> = LazyLock::new(|| {
21 Regex::new(r#"(?m)\bimport\s+([^;]+?)\s+from\s+["']([^"']+)["']"#).expect("import regex")
22});
23
24fn cached_call_query(
25 language_key: &'static str,
26 language: &Language,
27 query_str: &'static str,
28) -> Option<Arc<Query>> {
29 let key = (language_key, query_str.as_ptr() as usize);
30 let mut cache = CALL_QUERY_CACHE.lock().unwrap_or_else(|p| p.into_inner());
31 if let Some(q) = cache.get(&key) {
32 return Some(Arc::clone(q));
33 }
34 let q = match Query::new(language, query_str) {
35 Ok(q) => q,
36 Err(error) => {
37 #[cfg(test)]
38 {
39 panic!("invalid call graph query: {error}");
40 }
41 #[cfg(not(test))]
42 {
43 let _ = error;
44 return None;
45 }
46 }
47 };
48 let q = Arc::new(q);
49 cache.insert(key, Arc::clone(&q));
50 Some(q)
51}
52
53use crate::project::collect_files;
54
55#[derive(Debug, Clone, Serialize)]
56pub struct CallEdge {
57 pub caller_file: String,
58 pub caller_name: String,
59 pub callee_name: String,
60 pub line: usize,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub resolved_file: Option<String>,
64 pub confidence: f64,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub resolution_strategy: Option<&'static str>,
69 #[serde(skip_serializing)]
70 pub canonical_callee_name: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize)]
74pub struct CallerEntry {
75 pub file: String,
76 pub function: String,
77 pub line: usize,
78 pub confidence: f64,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub resolution: Option<&'static str>,
82}
83
84#[derive(Debug, Clone, Serialize)]
85pub struct CalleeEntry {
86 pub name: String,
87 pub line: usize,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub resolved_file: Option<String>,
90 pub confidence: f64,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub resolution: Option<&'static str>,
93}
94
95struct CallLanguageConfig {
96 language_key: &'static str,
98 language: Language,
99 func_query: &'static str,
101 call_query: &'static str,
103}
104
105#[derive(Debug, Clone)]
106struct JSImportBinding {
107 imported_name: Option<String>,
108 resolved_file: Option<String>,
109 external: bool,
110}
111
112type JSImportBindingIndex = HashMap<String, HashMap<String, JSImportBinding>>;
113
114pub(crate) fn is_noise_callee(name: &str) -> bool {
119 matches!(
120 name,
121 "get" | "set" | "push" | "pop" | "len" | "from" | "into"
123 | "map" | "filter" | "collect" | "contains" | "insert" | "remove"
124 | "format" | "print" | "clone" | "default" | "next" | "read"
125 | "write" | "open" | "close" | "keys" | "values" | "sort"
126 | "reverse" | "find" | "replace" | "delete" | "add" | "clear"
127 | "of" | "size" | "copy"
128 | "is_empty" | "to_string" | "to_owned" | "as_str" | "as_ref"
130 | "unwrap" | "expect" | "ok" | "err" | "and_then" | "or_else"
131 | "unwrap_or" | "unwrap_or_else" | "unwrap_or_default"
132 | "iter" | "into_iter" | "take" | "skip"
133 | "println" | "eprintln" | "drop" | "enter" | "lock" | "cloned"
134 | "range" | "enumerate" | "zip" | "sorted" | "reversed"
136 | "isinstance" | "issubclass" | "hasattr" | "getattr" | "setattr" | "delattr"
137 | "type" | "super" | "str" | "int" | "float" | "bool"
138 | "list" | "dict" | "tuple" | "frozenset" | "bytes" | "bytearray"
139 | "repr" | "abs" | "min" | "max" | "sum" | "any" | "all"
140 | "ord" | "chr" | "hex" | "oct" | "bin" | "hash" | "id"
141 | "input" | "vars" | "dir" | "help" | "round"
142 | "append" | "extend" | "update" | "items" | "join" | "split"
143 | "strip" | "startswith" | "endswith" | "encode" | "decode"
144 | "upper" | "lower"
145 | "log" | "warn" | "error" | "info" | "debug"
147 | "toString" | "valueOf" | "JSON" | "parse" | "stringify" | "assign"
148 | "entries" | "forEach" | "reduce" | "findIndex" | "some" | "every"
149 | "includes" | "indexOf" | "slice" | "splice" | "concat"
150 | "flat" | "flatMap" | "fill" | "isArray"
151 | "Promise" | "resolve" | "reject" | "then" | "catch" | "finally"
152 | "setTimeout" | "setInterval" | "clearTimeout" | "clearInterval"
153 | "parseInt" | "parseFloat" | "isNaN" | "isFinite" | "require"
154 | "make" | "cap" | "panic" | "recover" | "real" | "imag" | "complex"
156 | "Println" | "Printf" | "Sprintf" | "Fprintf" | "Errorf" | "New"
157 | "equals" | "hashCode" | "compareTo" | "getClass"
159 | "notify" | "notifyAll" | "wait" | "isEmpty"
160 | "addAll" | "containsKey" | "containsValue" | "put" | "putAll"
161 | "entrySet" | "keySet" | "charAt" | "substring" | "trim"
162 | "length" | "toArray" | "stream" | "asList"
163 )
164}
165
166pub(crate) fn is_noise_callee_for_lang(name: &str, lang: Option<&str>) -> bool {
168 if lang == Some("rs") && name == "new" {
169 return false;
170 }
171 is_noise_callee(name)
172}
173
174fn call_language_for_path(path: &Path) -> Option<CallLanguageConfig> {
175 let lang_config = crate::lang_config::language_for_path(path)?;
176 let (language_key, func_query, call_query) = match lang_config.extension {
178 "py" => ("py", PYTHON_FUNC_QUERY, PYTHON_CALL_QUERY),
179 "js" => ("js", JS_FUNC_QUERY, JS_JSX_CALL_QUERY),
180 "ts" => ("ts", JS_FUNC_QUERY, JS_CALL_QUERY),
181 "tsx" => ("tsx", JS_FUNC_QUERY, JS_JSX_CALL_QUERY),
182 "go" => ("go", GO_FUNC_QUERY, GO_CALL_QUERY),
183 "java" => ("java", JAVA_FUNC_QUERY, JAVA_CALL_QUERY),
184 "kt" => ("kt", KOTLIN_FUNC_QUERY, KOTLIN_CALL_QUERY),
185 "rs" => ("rs", RUST_FUNC_QUERY, RUST_CALL_QUERY),
186 _ => return None,
187 };
188 Some(CallLanguageConfig {
189 language_key,
190 language: lang_config.language,
191 func_query,
192 call_query,
193 })
194}
195
196fn collect_candidate_files(root: &Path) -> Result<Vec<PathBuf>> {
197 collect_files(root, |path| call_language_for_path(path).is_some())
198}
199
200fn is_import_sensitive_path(path: &str) -> bool {
201 matches!(
202 Path::new(path)
203 .extension()
204 .and_then(|value| value.to_str())
205 .unwrap_or_default(),
206 "js" | "jsx" | "ts" | "tsx"
207 )
208}
209
210fn is_external_module_specifier(module: &str, resolved_file: Option<&String>) -> bool {
211 resolved_file.is_none() && !module.starts_with('.') && !module.starts_with('/')
212}
213
214fn insert_js_binding(
215 bindings: &mut HashMap<String, JSImportBinding>,
216 local_name: &str,
217 imported_name: Option<&str>,
218 resolved_file: Option<&String>,
219 external: bool,
220) {
221 let local_name = local_name.trim().trim_start_matches("type ").trim();
222 if local_name.is_empty() {
223 return;
224 }
225 bindings.insert(
226 local_name.to_owned(),
227 JSImportBinding {
228 imported_name: imported_name
229 .map(|value| value.trim().trim_start_matches("type ").to_owned()),
230 resolved_file: resolved_file.cloned(),
231 external,
232 },
233 );
234}
235
236fn parse_js_import_bindings(
237 bindings: &mut HashMap<String, JSImportBinding>,
238 clause: &str,
239 resolved_file: Option<&String>,
240 module: &str,
241) {
242 let clause = clause.trim().trim_start_matches("type ").trim();
243 if clause.is_empty() {
244 return;
245 }
246 let external = is_external_module_specifier(module, resolved_file);
247
248 if let Some(stripped) = clause.strip_prefix("* as ") {
249 insert_js_binding(bindings, stripped, Some("*"), resolved_file, external);
250 return;
251 }
252
253 let mut default_part = clause;
254 if let Some(start) = clause.find('{') {
255 default_part = clause[..start].trim().trim_end_matches(',').trim();
256 if let Some(end) = clause[start + 1..].find('}') {
257 let named = &clause[start + 1..start + 1 + end];
258 for item in named.split(',') {
259 let item = item.trim().trim_start_matches("type ").trim();
260 if item.is_empty() {
261 continue;
262 }
263 if let Some((imported, local)) = item.split_once(" as ") {
264 insert_js_binding(bindings, local, Some(imported), resolved_file, external);
265 } else {
266 insert_js_binding(bindings, item, Some(item), resolved_file, external);
267 }
268 }
269 }
270 }
271
272 if !default_part.is_empty() {
273 insert_js_binding(bindings, default_part, None, resolved_file, external);
274 }
275}
276
277fn build_js_import_binding_index(project: &ProjectRoot, files: &[PathBuf]) -> JSImportBindingIndex {
278 let mut index = HashMap::new();
279 for file in files {
280 let relative = project.to_relative(file);
281 if !is_import_sensitive_path(&relative) {
282 continue;
283 }
284 let Ok(source) = fs::read_to_string(file) else {
285 continue;
286 };
287 let mut bindings = HashMap::new();
288 for capture in JS_IMPORT_FROM_RE.captures_iter(&source) {
289 let Some(clause) = capture.get(1).map(|value| value.as_str()) else {
290 continue;
291 };
292 let Some(module) = capture.get(2).map(|value| value.as_str()) else {
293 continue;
294 };
295 let resolved_file = crate::import_graph::resolve_module_for_file(project, file, module);
296 parse_js_import_bindings(&mut bindings, clause, resolved_file.as_ref(), module);
297 }
298 if !bindings.is_empty() {
299 index.insert(relative, bindings);
300 }
301 }
302 index
303}
304
305fn filter_external_import_edges(edges: &mut Vec<CallEdge>, import_bindings: &JSImportBindingIndex) {
306 edges.retain(|edge| {
307 import_bindings
308 .get(&edge.caller_file)
309 .and_then(|bindings| bindings.get(&edge.callee_name))
310 .map(|binding| !binding.external)
311 .unwrap_or(true)
312 });
313}
314
315fn maybe_import_graph(
316 project: &ProjectRoot,
317 files: &[PathBuf],
318 graph_cache: Option<&GraphCache>,
319) -> Option<Arc<HashMap<String, crate::import_graph::FileNode>>> {
320 let cache = graph_cache?;
321 let needs_import_graph = files.iter().any(|file| {
322 let relative = project.to_relative(file);
323 crate::import_graph::supports_import_graph(&relative)
324 });
325 if !needs_import_graph {
326 return None;
327 }
328 let mut graph = crate::import_graph::build_graph_pub(project, cache)
329 .map(|graph| (*graph).clone())
330 .unwrap_or_default();
331
332 for file in files {
333 let relative = project.to_relative(file);
334 if !crate::import_graph::supports_import_graph(&relative) {
335 continue;
336 }
337 let needs_patch = graph
338 .get(&relative)
339 .map(|node| node.imports.is_empty())
340 .unwrap_or(true);
341 if !needs_patch {
342 continue;
343 }
344
345 let imports: HashSet<String> = crate::import_graph::extract_imports_for_file(file)
346 .into_iter()
347 .filter_map(|module| {
348 crate::import_graph::resolve_module_for_file(project, file, &module)
349 })
350 .collect();
351 let entry =
352 graph
353 .entry(relative.clone())
354 .or_insert_with(|| crate::import_graph::FileNode {
355 imports: HashSet::new(),
356 imported_by: HashSet::new(),
357 });
358 entry.imports = imports.clone();
359
360 for imported_file in imports {
361 graph
362 .entry(imported_file)
363 .or_insert_with(|| crate::import_graph::FileNode {
364 imports: HashSet::new(),
365 imported_by: HashSet::new(),
366 })
367 .imported_by
368 .insert(relative.clone());
369 }
370 }
371
372 if graph.is_empty() {
373 None
374 } else {
375 Some(Arc::new(graph))
376 }
377}
378
379pub fn extract_calls(path: &Path) -> Vec<CallEdge> {
381 let Ok(source) = fs::read_to_string(path) else {
382 return Vec::new();
383 };
384 extract_calls_from_source(path, &source)
385}
386
387pub fn extract_calls_from_source(path: &Path, source: &str) -> Vec<CallEdge> {
389 let Some(config) = call_language_for_path(path) else {
390 return Vec::new();
391 };
392
393 let mut parser = Parser::new();
394 if parser.set_language(&config.language).is_err() {
395 return Vec::new();
396 }
397 let Some(tree) = parser.parse(source, None) else {
398 return Vec::new();
399 };
400 let source_bytes = source.as_bytes();
401
402 let Some(func_query) =
405 cached_call_query(config.language_key, &config.language, config.func_query)
406 else {
407 return Vec::new();
408 };
409 let mut func_ranges: Vec<(usize, usize, String)> = Vec::new(); let mut func_cursor = QueryCursor::new();
411 let mut func_matches = func_cursor.matches(&func_query, tree.root_node(), source_bytes);
412 while let Some(m) = func_matches.next() {
413 let mut def_range: Option<(usize, usize)> = None;
414 let mut func_name: Option<String> = None;
415 for cap in m.captures.iter() {
416 let cap_name = &func_query.capture_names()[cap.index as usize];
417 if *cap_name == "func.def" {
418 def_range = Some((cap.node.start_byte(), cap.node.end_byte()));
419 } else if *cap_name == "func.name" {
420 let start = cap.node.start_byte();
421 let end = cap.node.end_byte();
422 func_name = std::str::from_utf8(&source_bytes[start..end])
423 .ok()
424 .map(|s| s.trim().to_owned());
425 }
426 }
427 if let (Some((s, e)), Some(name)) = (def_range, func_name)
428 && !name.is_empty()
429 {
430 func_ranges.push((s, e, name));
431 }
432 }
433
434 let Some(call_query) =
436 cached_call_query(config.language_key, &config.language, config.call_query)
437 else {
438 return Vec::new();
439 };
440 let mut call_cursor = QueryCursor::new();
441 let mut call_matches = call_cursor.matches(&call_query, tree.root_node(), source_bytes);
442 let file_path = path.to_string_lossy().to_string();
443 let mut edges = Vec::new();
444
445 while let Some(m) = call_matches.next() {
446 for cap in m.captures.iter() {
447 let cap_name = &call_query.capture_names()[cap.index as usize];
448 if *cap_name != "callee" {
449 continue;
450 }
451 let start = cap.node.start_byte();
452 let end = cap.node.end_byte();
453 let Ok(callee_name) = std::str::from_utf8(&source_bytes[start..end]) else {
454 continue;
455 };
456 let callee_name = callee_name.trim().to_owned();
457 if callee_name.is_empty()
458 || is_noise_callee_for_lang(&callee_name, Some(config.language_key))
459 {
460 continue;
461 }
462 let line = cap.node.start_position().row + 1;
463
464 let caller_name = func_ranges
466 .iter()
467 .filter(|(fs, fe, _)| *fs <= start && *fe >= end)
468 .min_by_key(|(fs, fe, _)| fe - fs)
470 .map(|(_, _, name)| name.clone())
471 .unwrap_or_else(|| "<module>".to_owned());
472
473 edges.push(CallEdge {
474 caller_file: file_path.clone(),
475 caller_name,
476 callee_name,
477 line,
478 resolved_file: None,
479 confidence: 0.0,
480 resolution_strategy: None,
481 canonical_callee_name: None,
482 });
483 }
484 }
485
486 edges
487}
488
489fn resolve_call_edges(
494 edges: &mut [CallEdge],
495 project: &ProjectRoot,
496 import_graph: Option<&HashMap<String, crate::import_graph::FileNode>>,
497 import_bindings: Option<&JSImportBindingIndex>,
498) {
499 let db_path = crate::db::index_db_path(project.as_path());
501 let symbol_index: HashMap<String, Vec<String>> = crate::db::IndexDb::open(&db_path)
502 .and_then(|db| {
503 let all = db.all_symbol_names()?;
504 let mut map: HashMap<String, Vec<String>> = HashMap::new();
505 for (name, _kind, file, _line, _signature, _name_path) in all {
506 map.entry(name).or_default().push(file);
507 }
508 Ok(map)
509 })
510 .unwrap_or_default();
511
512 for edge in edges.iter_mut() {
513 if edge.confidence > 0.0 {
514 continue; }
516
517 let callee = &edge.callee_name;
518 let caller_file = &edge.caller_file;
519
520 if let Some(defs) = symbol_index.get(callee)
522 && defs.iter().any(|f| f == caller_file)
523 {
524 edge.resolved_file = Some(caller_file.clone());
525 edge.confidence = 0.90;
526 edge.resolution_strategy = Some("same_file");
527 continue;
528 }
529
530 if let Some(binding) = import_bindings
532 .and_then(|index| index.get(caller_file))
533 .and_then(|bindings| bindings.get(callee))
534 && let Some(resolved_file) = binding.resolved_file.as_ref()
535 {
536 let canonical_name = binding.imported_name.as_deref().unwrap_or(callee);
537 if let Some(defs) = symbol_index.get(canonical_name)
538 && defs.iter().any(|f| f == resolved_file)
539 {
540 edge.resolved_file = Some(resolved_file.clone());
541 edge.confidence = 0.95;
542 edge.resolution_strategy = Some("import_map");
543 edge.canonical_callee_name = Some(canonical_name.to_owned());
544 continue;
545 }
546 }
547
548 if let Some(graph) = import_graph
549 && let Some(node) = graph.get(caller_file)
550 {
551 for imported_file in &node.imports {
552 if let Some(defs) = symbol_index.get(callee)
554 && defs.iter().any(|f| f == imported_file)
555 {
556 edge.resolved_file = Some(imported_file.clone());
557 edge.confidence = 0.95;
558 edge.resolution_strategy = Some("import_map");
559 edge.canonical_callee_name = Some(callee.clone());
560 break;
561 }
562 }
563 }
564 if edge.confidence > 0.0 {
565 continue;
566 }
567
568 if let Some(graph) = import_graph
570 && let Some(node) = graph.get(caller_file)
571 && let Some(defs) = symbol_index.get(callee)
572 {
573 for def_file in defs {
575 if node.imports.iter().any(|imp| {
576 def_file.ends_with(imp)
578 || def_file.ends_with(&format!("/{imp}"))
579 || imp.ends_with(def_file)
580 || imp.ends_with(&format!("/{def_file}"))
581 }) {
582 edge.resolved_file = Some(def_file.clone());
583 edge.confidence = 0.70;
584 edge.resolution_strategy = Some("import_suffix");
585 edge.canonical_callee_name = Some(callee.clone());
586 break;
587 }
588 }
589 }
590 if edge.confidence > 0.0 {
591 continue;
592 }
593
594 if let Some(defs) = symbol_index.get(callee)
597 && defs.len() == 1
598 {
599 edge.resolved_file = Some(defs[0].clone());
600 if is_import_sensitive_path(caller_file) && defs[0].as_str() != caller_file.as_str() {
601 edge.confidence = 0.50;
602 edge.resolution_strategy = Some("path_proximity");
603 } else {
604 edge.confidence = 0.65;
605 edge.resolution_strategy = Some("unique_name");
606 }
607 continue;
608 }
609
610 if let Some(defs) = symbol_index.get(callee)
612 && !defs.is_empty()
613 {
614 let best = defs
616 .iter()
617 .max_by_key(|f| {
618 f.chars()
619 .zip(caller_file.chars())
620 .take_while(|(a, b)| a == b)
621 .count()
622 })
623 .cloned();
624 if let Some(f) = best {
625 edge.resolved_file = Some(f);
626 edge.confidence = 0.50;
627 edge.resolution_strategy = Some("path_proximity");
628 continue;
629 }
630 }
631
632 edge.confidence = 0.25;
634 edge.resolution_strategy = Some("unresolved");
635 }
636}
637
638pub fn get_callers(
641 project: &ProjectRoot,
642 function_name: &str,
643 file_path: Option<&str>,
644 max_results: usize,
645 graph_cache: Option<&GraphCache>,
646) -> Result<Vec<CallerEntry>> {
647 let files: Vec<PathBuf> = if let Some(fp) = file_path {
648 vec![project.resolve(fp)?]
649 } else {
650 collect_candidate_files(project.as_path())?
651 };
652 let mut all_edges: Vec<CallEdge> = Vec::new();
653
654 for file in &files {
655 let mut edges = extract_calls(file);
656 for edge in &mut edges {
658 edge.caller_file = project.to_relative(file);
659 }
660 all_edges.extend(edges);
661 }
662
663 let import_bindings = build_js_import_binding_index(project, &files);
664 filter_external_import_edges(&mut all_edges, &import_bindings);
665 let import_graph = maybe_import_graph(project, &files, graph_cache);
666 resolve_call_edges(
667 &mut all_edges,
668 project,
669 import_graph.as_deref(),
670 Some(&import_bindings),
671 );
672
673 let mut seen = std::collections::HashSet::new();
675 let mut results = Vec::new();
676
677 for edge in all_edges {
678 if edge.callee_name == function_name
679 || edge.canonical_callee_name.as_deref() == Some(function_name)
680 {
681 let key = (
682 edge.caller_file.clone(),
683 edge.caller_name.clone(),
684 edge.line,
685 );
686 if seen.insert(key) {
687 results.push(CallerEntry {
688 file: edge.caller_file,
689 function: edge.caller_name,
690 line: edge.line,
691 confidence: edge.confidence,
692 resolution: edge.resolution_strategy,
693 });
694 }
695 }
696 }
697
698 results.sort_by(|a, b| {
700 b.confidence
701 .partial_cmp(&a.confidence)
702 .unwrap_or(std::cmp::Ordering::Equal)
703 });
704 if max_results > 0 && results.len() > max_results {
705 results.truncate(max_results);
706 }
707 Ok(results)
708}
709
710pub fn get_callees(
713 project: &ProjectRoot,
714 function_name: &str,
715 file_path: Option<&str>,
716 max_results: usize,
717 graph_cache: Option<&GraphCache>,
718) -> Result<Vec<CalleeEntry>> {
719 let files: Vec<PathBuf> = if let Some(fp) = file_path {
720 let resolved = project.resolve(fp)?;
721 vec![resolved]
722 } else {
723 collect_candidate_files(project.as_path())?
724 };
725
726 let mut all_edges: Vec<CallEdge> = Vec::new();
727 for file in &files {
728 let mut edges = extract_calls(file);
729 for edge in &mut edges {
730 edge.caller_file = project.to_relative(file);
731 }
732 all_edges.extend(edges);
733 }
734
735 let import_bindings = build_js_import_binding_index(project, &files);
736 filter_external_import_edges(&mut all_edges, &import_bindings);
737 let import_graph = maybe_import_graph(project, &files, graph_cache);
738 resolve_call_edges(
739 &mut all_edges,
740 project,
741 import_graph.as_deref(),
742 Some(&import_bindings),
743 );
744
745 let mut seen: HashMap<(String, usize), ()> = HashMap::new();
746 let mut results = Vec::new();
747
748 for edge in all_edges {
749 if edge.caller_name == function_name {
750 let key = (edge.callee_name.clone(), edge.line);
751 if seen.insert(key, ()).is_none() {
752 results.push(CalleeEntry {
753 name: edge.callee_name,
754 line: edge.line,
755 resolved_file: edge.resolved_file,
756 confidence: edge.confidence,
757 resolution: edge.resolution_strategy,
758 });
759 }
760 }
761 }
762
763 results.sort_by(|a, b| {
764 b.confidence
765 .partial_cmp(&a.confidence)
766 .unwrap_or(std::cmp::Ordering::Equal)
767 });
768 if max_results > 0 && results.len() > max_results {
769 results.truncate(max_results);
770 }
771 Ok(results)
772}
773
774const PYTHON_FUNC_QUERY: &str = r#"
777(function_definition name: (identifier) @func.name) @func.def
778"#;
779
780const PYTHON_CALL_QUERY: &str = r#"
781(call function: (identifier) @callee)
782(call function: (attribute attribute: (identifier) @callee))
783(decorator (identifier) @callee)
784(decorator (call function: (identifier) @callee))
785(decorator (attribute attribute: (identifier) @callee))
786(decorator (call function: (attribute attribute: (identifier) @callee)))
787;; v1.11.1 (F1 follow-up): function-reference arguments. Python
788;; callback patterns include `register("evt", handler)`,
789;; `dispatcher.on(name, callback)`, `signal.connect(slot)`, plus
790;; decorator factories like `@retry(handler)`. The 6-stage
791;; resolution cascade filters identifier-arg captures against the
792;; project symbol DB; variable arguments fall to `unresolved` and
793;; genuine function references resolve via Stage 5 (`unique_name`)
794;; at confidence 0.5.
795(call arguments: (argument_list (identifier) @callee))
796(call arguments: (argument_list (attribute attribute: (identifier) @callee)))
797"#;
798
799const JS_FUNC_QUERY: &str = r#"
800(function_declaration name: (identifier) @func.name) @func.def
801(method_definition name: (property_identifier) @func.name) @func.def
802(lexical_declaration
803 (variable_declarator
804 name: (identifier) @func.name
805 value: [(arrow_function) (function_expression)] @func.def))
806(variable_declaration
807 (variable_declarator
808 name: (identifier) @func.name
809 value: [(arrow_function) (function_expression)] @func.def))
810"#;
811
812const JS_CALL_QUERY: &str = r#"
813(call_expression function: (identifier) @callee)
814(call_expression function: (member_expression property: (property_identifier) @callee))
815;; v1.11.1 (F1 follow-up): function-reference arguments. JS/TS frequently
816;; pass functions as callbacks — `setTimeout(handler, 100)`,
817;; `arr.map(parseLine)`, `bus.on("evt", onEvent)`, `.then(success)`.
818;; The 6-stage resolution cascade in `resolve_call_edges` filters these
819;; against the symbol DB, so variable arguments fall to `unresolved`
820;; while genuine function references resolve via Stage 5
821;; (`unique_name`) at confidence 0.5.
822(arguments (identifier) @callee)
823(arguments (member_expression property: (property_identifier) @callee))
824"#;
825
826const JS_JSX_CALL_QUERY: &str = r#"
830(call_expression function: (identifier) @callee)
831(call_expression function: (member_expression property: (property_identifier) @callee))
832(jsx_self_closing_element name: (identifier) @callee)
833(jsx_opening_element name: (identifier) @callee)
834(jsx_self_closing_element name: (member_expression property: (property_identifier) @callee))
835(jsx_opening_element name: (member_expression property: (property_identifier) @callee))
836;; v1.11.1: same function-reference patterns as JS_CALL_QUERY.
837(arguments (identifier) @callee)
838(arguments (member_expression property: (property_identifier) @callee))
839"#;
840
841const GO_FUNC_QUERY: &str = r#"
842(function_declaration name: (identifier) @func.name) @func.def
843(method_declaration name: (field_identifier) @func.name) @func.def
844"#;
845
846const GO_CALL_QUERY: &str = r#"
847(call_expression function: (identifier) @callee)
848(call_expression function: (selector_expression field: (field_identifier) @callee))
849;; v1.11.2 (F1 follow-up): function-reference arguments in Go.
850;; Catches `http.HandleFunc("/", handler)`, `time.AfterFunc(d, callback)`,
851;; `runtime.SetFinalizer(p, finalizer)`, and worker-pool dispatch
852;; patterns where a function value is passed by name. Same resolution
853;; cascade gating: variable arguments fall to `unresolved`, named
854;; functions resolve via Stage 5 (`unique_name`) at confidence 0.5.
855(argument_list (identifier) @callee)
856(argument_list (selector_expression field: (field_identifier) @callee))
857"#;
858
859const JAVA_FUNC_QUERY: &str = r#"
860(method_declaration name: (identifier) @func.name) @func.def
861(constructor_declaration name: (identifier) @func.name) @func.def
862"#;
863
864const JAVA_CALL_QUERY: &str = r#"
865(method_invocation name: (identifier) @callee)
866(object_creation_expression type: (type_identifier) @callee)
867(method_reference (identifier) @callee)
868;; v1.11.2 (F1 follow-up): function-reference arguments in Java/Kotlin
869;; that are passed as bare identifiers (callbacks, executor.submit
870;; targets) rather than the explicit `Class::method` reference syntax
871;; already covered above. The same query is shared with Kotlin via
872;; the `KOTLIN_FUNC_QUERY` mapping; tree-sitter-kotlin reuses
873;; `argument_list` node names for the call grammar so the pattern
874;; below applies to Kotlin call sites as well.
875(method_invocation arguments: (argument_list (identifier) @callee))
876(method_invocation arguments: (argument_list (field_access field: (identifier) @callee)))
877"#;
878
879const KOTLIN_FUNC_QUERY: &str = r#"
880(function_declaration (identifier) @func.name) @func.def
881"#;
882
883const KOTLIN_CALL_QUERY: &str = r#"
884;; Direct call: prepare()
885(call_expression (identifier) @callee)
886
887;; Method/navigation call: exec.submit(...) — last identifier in
888;; navigation_expression is the method name (anchor `.` selects last child).
889(call_expression
890 (navigation_expression
891 (identifier) @callee .))
892
893;; v1.12.3: function-reference arguments — submit(onTick),
894;; register("err", onError). Same noise-filter behavior as Rust:
895;; non-function identifiers (variables) are dropped at resolution time.
896(call_expression
897 (value_arguments
898 (value_argument
899 (identifier) @callee)))
900
901;; v1.12.4 (Codex P1): Kotlin callable references.
902;; - bare form `::onTick` parses as
903;; value_argument > callable_reference > identifier.
904;; - qualified form `this::onTick` parses as
905;; value_argument > navigation_expression(`::`) > identifier
906;; (tree-sitter-kotlin-ng folds the `::` token into a
907;; navigation_expression rather than a dedicated callable_reference
908;; node). Both shapes are common in Executor / event-bus callbacks.
909(call_expression
910 (value_arguments
911 (value_argument
912 (callable_reference (identifier) @callee))))
913
914(call_expression
915 (value_arguments
916 (value_argument
917 (navigation_expression (identifier) @callee .))))
918"#;
919
920const RUST_FUNC_QUERY: &str = r#"
921(function_item name: (identifier) @func.name) @func.def
922"#;
923
924const RUST_CALL_QUERY: &str = r#"
925(call_expression function: (identifier) @callee)
926(call_expression function: (field_expression field: (field_identifier) @callee))
927(call_expression function: (scoped_identifier name: (identifier) @callee))
928(macro_invocation macro: (identifier) @callee)
929(macro_invocation macro: (scoped_identifier name: (identifier) @callee))
930;; v1.11.0 (F1): function-reference patterns. A function passed as an
931;; argument (closure construction, callback registration, builder
932;; accumulators) is a real caller→callee edge that the call_expression
933;; rules above miss. Examples:
934;; LazyLock::new(build_tools)
935;; OnceCell::get_or_init(make_state)
936;; iter.map(parse_line).collect()
937;; bus.register("evt", on_event)
938;; Many argument identifiers are variables, not functions. The
939;; resolution cascade in `resolve_call_edges` filters those: the name
940;; must exist in the symbol DB or the edge is dropped as `unresolved`
941;; (confidence 0). Genuine function references resolve via Stage 5
942;; (unique_name) at confidence 0.5 — honest, lower than import_map but
943;; higher than nothing.
944(arguments (identifier) @callee)
945(arguments (scoped_identifier name: (identifier) @callee))
946"#;
947
948#[cfg(test)]
949mod tests {
950 use super::{CallEdge, extract_calls, get_callees, get_callers, resolve_call_edges};
951 use crate::GraphCache;
952 use crate::ProjectRoot;
953 use crate::db::{IndexDb, NewSymbol, index_db_path};
954 use std::fs;
955
956 fn temp_dir(name: &str) -> std::path::PathBuf {
957 let dir = std::env::temp_dir().join(format!(
958 "codelens-callgraph-{name}-{}",
959 std::time::SystemTime::now()
960 .duration_since(std::time::UNIX_EPOCH)
961 .expect("time")
962 .as_nanos()
963 ));
964 fs::create_dir_all(&dir).expect("create tempdir");
965 dir
966 }
967
968 #[test]
969 fn extracts_python_calls() {
970 let dir = temp_dir("py");
971 let path = dir.join("main.py");
972 fs::write(
973 &path,
974 "def greet(name):\n return helper(name)\n\ndef helper(x):\n return x\n",
975 )
976 .expect("write");
977 let edges = extract_calls(&path);
978 assert!(
979 edges
980 .iter()
981 .any(|e| e.caller_name == "greet" && e.callee_name == "helper"),
982 "expected greet->helper edge, got {edges:?}"
983 );
984 }
985
986 #[test]
987 fn extracts_python_decorator_callers() {
988 let dir = temp_dir("py-deco");
992 let path = dir.join("views.py");
993 fs::write(
994 &path,
995 "from flask import Flask\napp = Flask(__name__)\n\
996 @app.route('/')\ndef home():\n return 'hi'\n\n\
997 @app.route('/x')\ndef x_view():\n return 'x'\n",
998 )
999 .expect("write");
1000 let edges = extract_calls(&path);
1001 let route_edges = edges.iter().filter(|e| e.callee_name == "route").count();
1002 assert!(
1003 route_edges >= 2,
1004 "expected at least 2 caller edges for `route` decorator, got {route_edges}: {edges:?}"
1005 );
1006 }
1007
1008 #[test]
1009 fn extracts_jsx_component_callers() {
1010 let dir = temp_dir("tsx");
1015 let path = dir.join("page.tsx");
1016 fs::write(
1017 &path,
1018 "import Footer from './Footer';\nimport { Button } from './ui';\n\
1019 export default function Page() {\n return (<div><Footer />\n\
1020 <Button>OK</Button></div>);\n}\n",
1021 )
1022 .expect("write");
1023 let edges = extract_calls(&path);
1024 let footer_edges = edges.iter().filter(|e| e.callee_name == "Footer").count();
1025 let button_edges = edges.iter().filter(|e| e.callee_name == "Button").count();
1026 assert!(
1027 footer_edges >= 1,
1028 "expected at least 1 caller edge for `<Footer />`, got {footer_edges}: {edges:?}"
1029 );
1030 assert!(
1031 button_edges >= 1,
1032 "expected at least 1 caller edge for `<Button>`, got {button_edges}: {edges:?}"
1033 );
1034 }
1035
1036 #[test]
1037 fn extracts_rust_calls() {
1038 let dir = temp_dir("rs");
1039 let path = dir.join("main.rs");
1040 fs::write(&path, "fn main() {\n run();\n}\n\nfn run() {}\n").expect("write");
1041 let edges = extract_calls(&path);
1042 assert!(
1043 edges
1044 .iter()
1045 .any(|e| e.caller_name == "main" && e.callee_name == "run"),
1046 "expected main->run edge, got {edges:?}"
1047 );
1048 }
1049
1050 #[test]
1061 fn extracts_rust_macro_invocations_as_callers() {
1062 let dir = temp_dir("rs-macros");
1063 let path = dir.join("macros.rs");
1064 fs::write(
1065 &path,
1066 r#"macro_rules! my_log { ($($t:tt)*) => {} }
1067fn run() {
1068 let v = vec![1, 2, 3];
1069 assert_eq!(v.len(), 3);
1070 my_log!("hello");
1071}
1072"#,
1073 )
1074 .expect("write");
1075 let edges = extract_calls(&path);
1076 for expected in ["vec", "assert_eq", "my_log"] {
1077 assert!(
1078 edges
1079 .iter()
1080 .any(|e| e.caller_name == "run" && e.callee_name == expected),
1081 "expected run->{expected} macro edge, got {edges:?}"
1082 );
1083 }
1084 }
1085
1086 #[test]
1089 fn extracts_rust_scoped_macro_invocations() {
1090 let dir = temp_dir("rs-scoped-macros");
1091 let path = dir.join("scoped.rs");
1092 fs::write(
1093 &path,
1094 "fn run() {\n mycrate::trace_event!(\"hi\");\n helpers::record_metric!(42);\n}\n",
1095 )
1096 .expect("write");
1097 let edges = extract_calls(&path);
1098 for expected in ["trace_event", "record_metric"] {
1099 assert!(
1100 edges
1101 .iter()
1102 .any(|e| e.caller_name == "run" && e.callee_name == expected),
1103 "expected run->{expected} scoped macro edge, got {edges:?}"
1104 );
1105 }
1106 }
1107
1108 #[test]
1109 fn extracts_js_arrow_function_callers() {
1110 let dir = temp_dir("js-arrow");
1111 let path = dir.join("handler.js");
1112 fs::write(
1113 &path,
1114 "const handleRequest = async (req) => {\n validateUser(req);\n service.run(req);\n};\nfunction validateUser(req) { return req; }\n",
1115 )
1116 .expect("write");
1117 let edges = extract_calls(&path);
1118 assert!(
1119 edges
1120 .iter()
1121 .any(|e| e.caller_name == "handleRequest" && e.callee_name == "validateUser"),
1122 "expected handleRequest->validateUser edge, got {edges:?}"
1123 );
1124 }
1125
1126 #[test]
1130 fn extracts_java_constructor_invocations() {
1131 let dir = temp_dir("java-ctor");
1132 let path = dir.join("App.java");
1133 fs::write(
1134 &path,
1135 "class App { void caller() { Foo f = new Foo(); Bar b = new Bar(1, 2); f.process(); } }\n",
1136 )
1137 .expect("write");
1138 let edges = extract_calls(&path);
1139 for expected in ["Foo", "Bar", "process"] {
1140 assert!(
1141 edges
1142 .iter()
1143 .any(|e| e.caller_name == "caller" && e.callee_name == expected),
1144 "expected caller->{expected} edge, got {edges:?}"
1145 );
1146 }
1147 }
1148
1149 #[test]
1156 fn extracts_java_method_references() {
1157 let dir = temp_dir("java-mref");
1158 let path = dir.join("App.java");
1159 fs::write(
1160 &path,
1161 "class App { void caller(Bus b) { b.attach(Handler::dispatchEvent); b.subscribe(MyService::handleRequest); } }\n",
1162 )
1163 .expect("write");
1164 let edges = extract_calls(&path);
1165 for expected in ["attach", "dispatchEvent", "subscribe", "handleRequest"] {
1166 assert!(
1167 edges
1168 .iter()
1169 .any(|e| e.caller_name == "caller" && e.callee_name == expected),
1170 "expected caller->{expected} edge, got {edges:?}"
1171 );
1172 }
1173 }
1174
1175 #[test]
1176 fn extracts_ts_typed_arrow_function_callers() {
1177 let dir = temp_dir("ts-arrow");
1178 let path = dir.join("handler.ts");
1179 fs::write(
1180 &path,
1181 "type Request = { userId: string };\nconst handleRequest = async (req: Request): Promise<Request> => {\n return validateUser(req);\n};\nfunction validateUser(req: Request) { return req; }\n",
1182 )
1183 .expect("write");
1184 let edges = extract_calls(&path);
1185 assert!(
1186 edges
1187 .iter()
1188 .any(|e| e.caller_name == "handleRequest" && e.callee_name == "validateUser"),
1189 "expected handleRequest->validateUser edge, got {edges:?}"
1190 );
1191 }
1192
1193 #[test]
1194 fn shared_js_ts_queries_do_not_cross_language_cache() {
1195 let dir = temp_dir("js-ts-cache");
1196 let js_path = dir.join("handler.js");
1197 let ts_path = dir.join("handler.ts");
1198 fs::write(
1199 &js_path,
1200 "const handleJs = () => {\n validateJs();\n};\nfunction validateJs() {}\n",
1201 )
1202 .expect("write js");
1203 fs::write(
1204 &ts_path,
1205 "type Request = { userId: string };\nconst handleTs = (req: Request): Request => {\n return validateTs(req);\n};\nfunction validateTs(req: Request) { return req; }\n",
1206 )
1207 .expect("write ts");
1208
1209 let js_edges = extract_calls(&js_path);
1210 assert!(
1211 js_edges
1212 .iter()
1213 .any(|e| e.caller_name == "handleJs" && e.callee_name == "validateJs"),
1214 "expected handleJs->validateJs edge, got {js_edges:?}"
1215 );
1216
1217 let ts_edges = extract_calls(&ts_path);
1218 assert!(
1219 ts_edges
1220 .iter()
1221 .any(|e| e.caller_name == "handleTs" && e.callee_name == "validateTs"),
1222 "expected handleTs->validateTs edge after JS extraction, got {ts_edges:?}"
1223 );
1224 }
1225
1226 #[test]
1227 fn extracts_rust_scoped_function_calls() {
1228 let dir = temp_dir("rs-scoped");
1229 let path = dir.join("main.rs");
1230 fs::write(
1231 &path,
1232 "mod auth { pub fn verify() {} }\nfn handler() {\n auth::verify();\n}\n",
1233 )
1234 .expect("write");
1235 let edges = extract_calls(&path);
1236 assert!(
1237 edges
1238 .iter()
1239 .any(|e| e.caller_name == "handler" && e.callee_name == "verify"),
1240 "expected handler->verify edge, got {edges:?}"
1241 );
1242 }
1243
1244 #[test]
1259 fn extracts_rust_function_reference_arguments() {
1260 let dir = temp_dir("rs-fn-refs");
1261 let path = dir.join("registry.rs");
1262 fs::write(
1263 &path,
1264 r#"
1265fn build_tools() -> Vec<u32> { vec![1, 2, 3] }
1266fn parse_line(s: &str) -> u32 { s.len() as u32 }
1267
1268static TOOLS: std::sync::LazyLock<Vec<u32>> =
1269 std::sync::LazyLock::new(build_tools);
1270
1271fn run() {
1272 let lines = ["a", "bb"];
1273 let parsed: Vec<_> = lines.iter().map(parse_line).collect();
1274 let _ = parsed;
1275}
1276"#,
1277 )
1278 .expect("write");
1279 let edges = extract_calls(&path);
1280 assert!(
1281 edges.iter().any(|e| e.callee_name == "build_tools"),
1282 "expected a function-reference caller for build_tools, got {edges:?}"
1283 );
1284 assert!(
1285 edges.iter().any(|e| e.callee_name == "parse_line"),
1286 "expected a function-reference caller for parse_line, got {edges:?}"
1287 );
1288 }
1289
1290 #[test]
1296 fn extracts_js_function_reference_arguments() {
1297 let dir = temp_dir("js-fn-refs");
1298 let path = dir.join("callbacks.js");
1299 fs::write(
1300 &path,
1301 r#"
1302function parseLine(line) { return line.trim(); }
1303function onEvent(payload) { return payload; }
1304function timeoutHandler() { return 1; }
1305
1306function setup() {
1307 const lines = ["a", "b"];
1308 const parsed = lines.map(parseLine);
1309 bus.on("evt", onEvent);
1310 setTimeout(timeoutHandler, 100);
1311 return parsed;
1312}
1313"#,
1314 )
1315 .expect("write");
1316 let edges = extract_calls(&path);
1317 for callee in ["parseLine", "onEvent", "timeoutHandler"] {
1318 assert!(
1319 edges
1320 .iter()
1321 .any(|e| e.caller_name == "setup" && e.callee_name == callee),
1322 "expected setup->{callee} function-reference edge, got {edges:?}"
1323 );
1324 }
1325 }
1326
1327 #[test]
1333 fn extracts_python_function_reference_arguments() {
1334 let dir = temp_dir("py-fn-refs");
1335 let path = dir.join("registry.py");
1336 fs::write(
1337 &path,
1338 r#"
1339def parse_line(line):
1340 return line.strip()
1341
1342def on_event(payload):
1343 return payload
1344
1345def setup():
1346 register("evt", on_event)
1347 pipe = list(map(parse_line, ["a", "b"]))
1348 return pipe
1349"#,
1350 )
1351 .expect("write");
1352 let edges = extract_calls(&path);
1353 for callee in ["parse_line", "on_event"] {
1354 assert!(
1355 edges
1356 .iter()
1357 .any(|e| e.caller_name == "setup" && e.callee_name == callee),
1358 "expected setup->{callee} function-reference edge, got {edges:?}"
1359 );
1360 }
1361 }
1362
1363 #[test]
1369 fn extracts_go_function_reference_arguments() {
1370 let dir = temp_dir("go-fn-refs");
1371 let path = dir.join("server.go");
1372 fs::write(
1373 &path,
1374 r#"package main
1375
1376func handler(w int, r int) {}
1377func teardown() {}
1378
1379func setup() {
1380 Register("/api", handler)
1381 Schedule(teardown)
1382}
1383"#,
1384 )
1385 .expect("write");
1386 let edges = extract_calls(&path);
1387 for callee in ["handler", "teardown"] {
1388 assert!(
1389 edges
1390 .iter()
1391 .any(|e| e.caller_name == "setup" && e.callee_name == callee),
1392 "expected setup->{callee} function-reference edge, got {edges:?}"
1393 );
1394 }
1395 }
1396
1397 #[test]
1402 fn extracts_java_function_reference_arguments() {
1403 let dir = temp_dir("java-fn-refs");
1404 let path = dir.join("Service.java");
1405 fs::write(
1406 &path,
1407 r#"public class Service {
1408 public void onTick() {}
1409 public void onError(String e) {}
1410
1411 public void start(Executor exec, Bus bus) {
1412 exec.submit(onTick);
1413 bus.register("err", onError);
1414 }
1415}
1416"#,
1417 )
1418 .expect("write");
1419 let edges = extract_calls(&path);
1420 for callee in ["onTick", "onError"] {
1421 assert!(
1422 edges
1423 .iter()
1424 .any(|e| e.caller_name == "start" && e.callee_name == callee),
1425 "expected start->{callee} function-reference edge, got {edges:?}"
1426 );
1427 }
1428 }
1429
1430 #[test]
1439 fn function_reference_extraction_is_resilient_to_variable_arguments() {
1440 let dir = temp_dir("rs-fn-ref-noise");
1441 let path = dir.join("noise.rs");
1442 fs::write(
1443 &path,
1444 r#"
1445fn outer(local_var: i32) {
1446 println!("v={}", local_var);
1447 let other = local_var + 1;
1448 consume(other);
1449}
1450fn consume(x: i32) -> i32 { x }
1451"#,
1452 )
1453 .expect("write");
1454 let edges = extract_calls(&path);
1456 assert!(
1457 edges
1458 .iter()
1459 .any(|e| e.caller_name == "outer" && e.callee_name == "consume"),
1460 "direct call edge outer->consume must survive function-reference extraction, got {edges:?}"
1461 );
1462 }
1463
1464 #[test]
1465 fn get_callers_finds_callers() {
1466 let dir = temp_dir("callers");
1467 fs::write(dir.join("a.py"), "def foo():\n bar()\n baz()\n").expect("write a");
1468 fs::write(dir.join("b.py"), "def qux():\n bar()\n").expect("write b");
1469 fs::write(dir.join("c.py"), "def bar():\n pass\n").expect("write c");
1470
1471 let project = ProjectRoot::new(&dir).expect("project");
1472 let callers = get_callers(&project, "bar", None, 50, None).expect("callers");
1473 let names: Vec<&str> = callers.iter().map(|c| c.function.as_str()).collect();
1474 assert!(
1475 names.contains(&"foo"),
1476 "expected foo as caller, got {names:?}"
1477 );
1478 assert!(
1479 names.contains(&"qux"),
1480 "expected qux as caller, got {names:?}"
1481 );
1482 }
1483
1484 #[test]
1485 fn get_callees_finds_callees() {
1486 let dir = temp_dir("callees");
1487 fs::write(
1488 dir.join("main.py"),
1489 "def main():\n foo()\n bar()\n\ndef foo():\n pass\n\ndef bar():\n pass\n",
1490 )
1491 .expect("write");
1492
1493 let project = ProjectRoot::new(&dir).expect("project");
1494 let callees = get_callees(&project, "main", None, 50, None).expect("callees");
1495 let names: Vec<&str> = callees.iter().map(|c| c.name.as_str()).collect();
1496 assert!(
1497 names.contains(&"foo"),
1498 "expected foo as callee, got {names:?}"
1499 );
1500 assert!(
1501 names.contains(&"bar"),
1502 "expected bar as callee, got {names:?}"
1503 );
1504 }
1505
1506 #[test]
1507 fn get_callees_resolves_definition_file_path() {
1508 let dir = temp_dir("callees-file-path");
1509 fs::write(dir.join("main.py"), "def main():\n helper()\n").expect("write main");
1510 fs::write(dir.join("helpers.py"), "def helper():\n pass\n").expect("write helper");
1511 let db = IndexDb::open(&index_db_path(&dir)).expect("db");
1512 let helper_file = db
1513 .upsert_file("helpers.py", 100, "helpers", 24, Some("py"))
1514 .expect("helpers file");
1515 db.insert_symbols(
1516 helper_file,
1517 &[NewSymbol {
1518 name: "helper",
1519 kind: "function",
1520 line: 1,
1521 column_num: 0,
1522 start_byte: 0,
1523 end_byte: 24,
1524 signature: "def helper():",
1525 name_path: "helper",
1526 parent_id: None,
1527 }],
1528 )
1529 .expect("helper symbol");
1530
1531 let project = ProjectRoot::new(&dir).expect("project");
1532 let callees = get_callees(&project, "main", Some("main.py"), 50, None).expect("callees");
1533 let helper = callees
1534 .iter()
1535 .find(|callee| callee.name == "helper")
1536 .expect("helper callee");
1537
1538 assert_eq!(helper.resolved_file.as_deref(), Some("helpers.py"));
1539 }
1540
1541 #[test]
1542 fn ts_cross_file_unique_resolution_is_fallback_without_import_evidence() {
1543 let dir = temp_dir("ts-cross-file-unique");
1544 fs::write(
1545 dir.join("page.tsx"),
1546 "export function Page() { handleSubmit(); }\n",
1547 )
1548 .expect("write page");
1549 fs::create_dir_all(dir.join("components")).expect("components");
1550 fs::write(
1551 dir.join("components").join("CommentSection.tsx"),
1552 "export function handleSubmit() {}\n",
1553 )
1554 .expect("write component");
1555 let db = IndexDb::open(&index_db_path(&dir)).expect("db");
1556 let file_id = db
1557 .upsert_file(
1558 "components/CommentSection.tsx",
1559 100,
1560 "component",
1561 34,
1562 Some("tsx"),
1563 )
1564 .expect("component file");
1565 db.insert_symbols(
1566 file_id,
1567 &[NewSymbol {
1568 name: "handleSubmit",
1569 kind: "function",
1570 line: 1,
1571 column_num: 0,
1572 start_byte: 0,
1573 end_byte: 34,
1574 signature: "export function handleSubmit() {}",
1575 name_path: "handleSubmit",
1576 parent_id: None,
1577 }],
1578 )
1579 .expect("component symbol");
1580
1581 let project = ProjectRoot::new(&dir).expect("project");
1582 let mut edges = vec![CallEdge {
1583 caller_file: "page.tsx".to_owned(),
1584 caller_name: "Page".to_owned(),
1585 callee_name: "handleSubmit".to_owned(),
1586 line: 1,
1587 resolved_file: None,
1588 confidence: 0.0,
1589 resolution_strategy: None,
1590 canonical_callee_name: None,
1591 }];
1592
1593 resolve_call_edges(&mut edges, &project, None, None);
1594
1595 assert_eq!(
1596 edges[0].resolved_file.as_deref(),
1597 Some("components/CommentSection.tsx")
1598 );
1599 assert_eq!(edges[0].resolution_strategy, Some("path_proximity"));
1600 assert!(edges[0].confidence <= 0.60);
1601 }
1602
1603 #[test]
1604 fn get_callees_scoped_to_file() {
1605 let dir = temp_dir("callees-file");
1606 fs::write(dir.join("a.py"), "def process():\n helper()\n").expect("write a");
1607 fs::write(dir.join("b.py"), "def process():\n other()\n").expect("write b");
1608
1609 let project = ProjectRoot::new(&dir).expect("project");
1610 let callees = get_callees(&project, "process", Some("a.py"), 50, None).expect("callees");
1611 let names: Vec<&str> = callees.iter().map(|c| c.name.as_str()).collect();
1612 assert!(names.contains(&"helper"), "expected helper, got {names:?}");
1613 assert!(!names.contains(&"other"), "should not have other from b.py");
1614 }
1615
1616 #[test]
1617 fn get_callers_scoped_to_file() {
1618 let dir = temp_dir("callers-file");
1619 fs::write(dir.join("a.py"), "def foo():\n bar()\n").expect("write a");
1620 fs::write(dir.join("b.py"), "def qux():\n bar()\n").expect("write b");
1621 fs::write(dir.join("c.py"), "def bar():\n pass\n").expect("write c");
1622
1623 let project = ProjectRoot::new(&dir).expect("project");
1624 let callers = get_callers(&project, "bar", Some("a.py"), 50, None).expect("callers");
1625 let names: Vec<&str> = callers.iter().map(|c| c.function.as_str()).collect();
1626 assert_eq!(names, vec!["foo"]);
1627 }
1628
1629 #[test]
1630 fn ts_cross_file_resolution_prefers_import_evidence() {
1631 let dir = temp_dir("ts-import-map");
1632 fs::write(
1633 dir.join("page.tsx"),
1634 "import { handleSubmit } from \"./actions\";\nexport function Page() { handleSubmit(); }\n",
1635 )
1636 .expect("write page");
1637 fs::write(
1638 dir.join("actions.ts"),
1639 "export function handleSubmit() {}\n",
1640 )
1641 .expect("write actions");
1642 let db = IndexDb::open(&index_db_path(&dir)).expect("db");
1643 let file_id = db
1644 .upsert_file("actions.ts", 100, "actions", 34, Some("ts"))
1645 .expect("actions file");
1646 db.insert_symbols(
1647 file_id,
1648 &[NewSymbol {
1649 name: "handleSubmit",
1650 kind: "function",
1651 line: 1,
1652 column_num: 0,
1653 start_byte: 0,
1654 end_byte: 34,
1655 signature: "export function handleSubmit() {}",
1656 name_path: "handleSubmit",
1657 parent_id: None,
1658 }],
1659 )
1660 .expect("action symbol");
1661
1662 let project = ProjectRoot::new(&dir).expect("project");
1663 let cache = GraphCache::new(0);
1664 let callees =
1665 get_callees(&project, "Page", Some("page.tsx"), 50, Some(&cache)).expect("callees");
1666 let submit = callees
1667 .iter()
1668 .find(|callee| callee.name == "handleSubmit")
1669 .expect("handleSubmit callee");
1670 assert_eq!(submit.resolved_file.as_deref(), Some("actions.ts"));
1671 assert!(
1672 matches!(submit.resolution, Some("import_map" | "import_suffix")),
1673 "expected import evidence resolution, got {:?}",
1674 submit.resolution
1675 );
1676 }
1677
1678 #[test]
1679 fn same_file_beats_import_match() {
1680 let dir = temp_dir("same-file-over-import");
1681 fs::write(
1682 dir.join("page.ts"),
1683 "import { helper } from \"./helpers\";\nfunction helper() {}\nexport function main() { helper(); }\n",
1684 )
1685 .expect("write page");
1686 fs::write(dir.join("helpers.ts"), "export function helper() {}\n").expect("write helpers");
1687 let db = IndexDb::open(&index_db_path(&dir)).expect("db");
1688 let page_file = db
1689 .upsert_file("page.ts", 100, "page", 92, Some("ts"))
1690 .expect("page file");
1691 let helpers_file = db
1692 .upsert_file("helpers.ts", 100, "helpers", 28, Some("ts"))
1693 .expect("helpers file");
1694 db.insert_symbols(
1695 page_file,
1696 &[NewSymbol {
1697 name: "helper",
1698 kind: "function",
1699 line: 2,
1700 column_num: 0,
1701 start_byte: 37,
1702 end_byte: 57,
1703 signature: "function helper() {}",
1704 name_path: "helper",
1705 parent_id: None,
1706 }],
1707 )
1708 .expect("page helper symbol");
1709 db.insert_symbols(
1710 helpers_file,
1711 &[NewSymbol {
1712 name: "helper",
1713 kind: "function",
1714 line: 1,
1715 column_num: 0,
1716 start_byte: 0,
1717 end_byte: 28,
1718 signature: "export function helper() {}",
1719 name_path: "helper",
1720 parent_id: None,
1721 }],
1722 )
1723 .expect("imported helper symbol");
1724
1725 let project = ProjectRoot::new(&dir).expect("project");
1726 let cache = GraphCache::new(0);
1727 let callees =
1728 get_callees(&project, "main", Some("page.ts"), 50, Some(&cache)).expect("callees");
1729 let helper = callees
1730 .iter()
1731 .find(|callee| callee.name == "helper")
1732 .expect("helper callee");
1733 assert_eq!(helper.resolved_file.as_deref(), Some("page.ts"));
1734 assert_eq!(helper.resolution, Some("same_file"));
1735 }
1736
1737 #[test]
1738 fn ts_import_alias_resolves_and_callers_match_canonical_name() {
1739 let dir = temp_dir("ts-import-alias");
1740 fs::write(
1741 dir.join("page.tsx"),
1742 "import { handleSubmit as onSubmit } from \"./actions\";\nexport function Page() { onSubmit(); }\n",
1743 )
1744 .expect("write page");
1745 fs::write(
1746 dir.join("actions.ts"),
1747 "export function handleSubmit() {}\n",
1748 )
1749 .expect("write actions");
1750 let db = IndexDb::open(&index_db_path(&dir)).expect("db");
1751 let file_id = db
1752 .upsert_file("actions.ts", 100, "actions", 34, Some("ts"))
1753 .expect("actions file");
1754 db.insert_symbols(
1755 file_id,
1756 &[NewSymbol {
1757 name: "handleSubmit",
1758 kind: "function",
1759 line: 1,
1760 column_num: 0,
1761 start_byte: 0,
1762 end_byte: 34,
1763 signature: "export function handleSubmit() {}",
1764 name_path: "handleSubmit",
1765 parent_id: None,
1766 }],
1767 )
1768 .expect("action symbol");
1769
1770 let project = ProjectRoot::new(&dir).expect("project");
1771 let cache = GraphCache::new(0);
1772 let callees =
1773 get_callees(&project, "Page", Some("page.tsx"), 50, Some(&cache)).expect("callees");
1774 let submit = callees
1775 .iter()
1776 .find(|callee| callee.name == "onSubmit")
1777 .expect("aliased callee");
1778 assert_eq!(submit.resolved_file.as_deref(), Some("actions.ts"));
1779 assert_eq!(submit.resolution, Some("import_map"));
1780
1781 let callers =
1782 get_callers(&project, "handleSubmit", None, 50, Some(&cache)).expect("callers");
1783 let page = callers
1784 .iter()
1785 .find(|caller| caller.function == "Page")
1786 .expect("Page caller");
1787 assert_eq!(page.file, "page.tsx");
1788 }
1789
1790 #[test]
1791 fn ts_external_import_calls_are_filtered_from_project_graph() {
1792 let dir = temp_dir("ts-external-import-filter");
1793 fs::write(
1794 dir.join("page.tsx"),
1795 "import { useState } from \"react\";\nimport { handleSubmit } from \"./actions\";\nexport function Page() { useState(); handleSubmit(); }\n",
1796 )
1797 .expect("write page");
1798 fs::write(
1799 dir.join("actions.ts"),
1800 "export function handleSubmit() {}\n",
1801 )
1802 .expect("write actions");
1803 let db = IndexDb::open(&index_db_path(&dir)).expect("db");
1804 let file_id = db
1805 .upsert_file("actions.ts", 100, "actions", 34, Some("ts"))
1806 .expect("actions file");
1807 db.insert_symbols(
1808 file_id,
1809 &[NewSymbol {
1810 name: "handleSubmit",
1811 kind: "function",
1812 line: 1,
1813 column_num: 0,
1814 start_byte: 0,
1815 end_byte: 34,
1816 signature: "export function handleSubmit() {}",
1817 name_path: "handleSubmit",
1818 parent_id: None,
1819 }],
1820 )
1821 .expect("action symbol");
1822
1823 let project = ProjectRoot::new(&dir).expect("project");
1824 let cache = GraphCache::new(0);
1825 let callees =
1826 get_callees(&project, "Page", Some("page.tsx"), 50, Some(&cache)).expect("callees");
1827 assert!(
1828 callees.iter().any(|callee| callee.name == "handleSubmit"),
1829 "expected internal imported callee in {callees:?}"
1830 );
1831 assert!(
1832 !callees.iter().any(|callee| callee.name == "useState"),
1833 "external imported binding should not appear in project call graph: {callees:?}"
1834 );
1835 }
1836
1837 #[test]
1838 fn get_callers_finds_rust_new_constructor() {
1839 let dir = temp_dir("rs-callers-new");
1840 fs::write(
1841 dir.join("lib.rs"),
1842 r#"pub struct Foo;
1843impl Foo {
1844 pub fn new() -> Self { Self }
1845}
1846
1847pub fn make_foo() -> Foo {
1848 Foo::new()
1849}
1850
1851pub fn make_another() -> Foo {
1852 Self::new()
1853}
1854"#,
1855 )
1856 .expect("write lib.rs");
1857
1858 let project = ProjectRoot::new(&dir).expect("project");
1859 let callers = get_callers(&project, "new", None, 50, None).expect("callers");
1860 let names: Vec<&str> = callers.iter().map(|c| c.function.as_str()).collect();
1861 assert!(
1862 names.contains(&"make_foo"),
1863 "expected make_foo as caller of new, got {names:?}"
1864 );
1865 assert!(
1866 names.contains(&"make_another"),
1867 "expected make_another as caller of new, got {names:?}"
1868 );
1869 }
1870}