Skip to main content

codelens_engine/
call_graph.rs

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
14/// Cached compiled tree-sitter Query for call graph extraction.
15/// Key: (canonical language key, query string pointer as usize).
16type 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    /// Resolved file where the callee is defined (None if unresolved).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub resolved_file: Option<String>,
64    /// Confidence of the resolution (0.0–1.0). Higher = more certain.
65    pub confidence: f64,
66    /// Which resolution strategy succeeded.
67    #[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    /// Confidence that this caller actually calls the target (0.0–1.0).
79    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    /// Stable language/cache key. JS and TS can share query text but not compiled queries.
97    language_key: &'static str,
98    language: Language,
99    /// Query to find function definitions: captures @func.name
100    func_query: &'static str,
101    /// Query to find call sites: captures @callee
102    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
114/// Resolve call graph config via the unified language registry.
115/// Only a subset of languages have call graph queries defined.
116/// Filter out common std/builtin method calls that add noise to the call graph.
117/// Covers Rust std, Python builtins, JS/TS builtins, Go builtins, and Java/Kotlin stdlib.
118pub(crate) fn is_noise_callee(name: &str) -> bool {
119    matches!(
120        name,
121        // ── cross-language common ──
122        "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            // ── Rust std ──
129            | "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            // ── Python builtins ──
135            | "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            // ── JS/TS builtins ──
146            | "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            // ── Go builtins ──
155            | "make" | "cap" | "panic" | "recover" | "real" | "imag" | "complex"
156            | "Println" | "Printf" | "Sprintf" | "Fprintf" | "Errorf" | "New"
157            // ── Java/Kotlin stdlib ──
158            | "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
166/// Language-aware noise filter. Rust `new` is a constructor, not noise.
167pub(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    // Map canonical extension to call graph queries (not all languages support this)
177    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
379/// Parse a file and extract all call edges within each function.
380pub 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
387/// Extract call edges from already-loaded source content (avoids re-reading disk).
388pub 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    // Build a map: byte_range_start -> caller_name for each function definition.
403    // We'll use this to find which function contains each call site.
404    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(); // (start, end, name)
410    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    // Parse call sites
435    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            // Find the enclosing function
465            let caller_name = func_ranges
466                .iter()
467                .filter(|(fs, fe, _)| *fs <= start && *fe >= end)
468                // pick the innermost (smallest range)
469                .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
489// ── 6-stage call resolution cascade ──────────────────────────────────────
490
491/// Resolve callee names to their definition files using a 6-stage confidence cascade.
492/// Mutates edges in-place, setting resolved_file, confidence, and resolution_strategy.
493fn 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    // Build a name→files index from the symbol DB for stages 3-5
500    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; // already resolved
515        }
516
517        let callee = &edge.callee_name;
518        let caller_file = &edge.caller_file;
519
520        // Stage 1: Same file — local definitions beat imported or project-wide matches (0.90)
521        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        // Stage 2: Import map — imported target defines the callee (0.95)
531        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                // Check if imported file defines callee
553                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        // Stage 3: Import suffix — imported module suffix points at the callee (0.70)
569        if let Some(graph) = import_graph
570            && let Some(node) = graph.get(caller_file)
571            && let Some(defs) = symbol_index.get(callee)
572        {
573            // Pick the candidate that is also imported (transitively)
574            for def_file in defs {
575                if node.imports.iter().any(|imp| {
576                    // Match on full path suffix, not just filename
577                    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        // Stage 4: Unique name — only one definition exists project-wide (0.65).
595        // For JS/TS cross-file calls without import evidence, keep this as a fallback.
596        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        // Stage 5: Multiple candidates — pick closest by path similarity (0.50)
611        if let Some(defs) = symbol_index.get(callee)
612            && !defs.is_empty()
613        {
614            // Pick the one with the most shared path prefix with caller_file
615            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        // Stage 6: Unresolved — callee not found in symbol DB (0.25)
633        edge.confidence = 0.25;
634        edge.resolution_strategy = Some("unresolved");
635    }
636}
637
638/// Find all functions that call `function_name` across the project.
639/// Edges are resolved via the 6-stage confidence cascade when an import graph is available.
640pub 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        // Relativize caller_file paths
657        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    // Filter to edges calling our target
674    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    // Sort by confidence descending
699    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
710/// Find all functions called by `function_name` (optionally restricted to a file).
711/// Callee names are resolved to their definition files via the 6-stage cascade.
712pub 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
774// ---- Tree-sitter queries ----
775
776const 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
826// JSX/TSX adds React-style component usage (`<Foo />`, `<Foo>`) as caller→callee
827// edges. Plain TypeScript (.ts) has no JSX node types — keep this off the JS/TS
828// path. tree-sitter-javascript also supports JSX, so .jsx files share this set.
829const 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        // Python decorator pattern is THE most common Flask/FastAPI/click usage.
989        // tree-sitter call extractor previously missed it entirely (Flask: 1/292
990        // recall on `route`). Decorators must be treated as caller→callee edges.
991        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        // JSX <Component /> usage is THE core React pattern. Previously
1011        // tree-sitter call extractor missed it entirely (rg-family: 0/14
1012        // on `<Footer />`). JSX elements must be treated as caller→callee
1013        // edges to the component function.
1014        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    /// Rust macro invocations (`vec!`, `assert_eq!`, project-defined macros,
1051    /// scoped macros like `mycrate::log!`) are extremely common — but before
1052    /// 2026-04-26 they were silently dropped from the call graph because
1053    /// `macro_invocation` is a distinct AST node from `call_expression`.
1054    ///
1055    /// `println!` / `eprintln!` / `format!` / `print!` are intentionally
1056    /// filtered by `is_noise_callee` to keep std-debug lines out of the
1057    /// graph; the query DOES discover them but the noise filter drops them.
1058    /// Project-named macros and `vec!` / `assert_eq!` survive — those are
1059    /// the meaningful edges this PR unlocks.
1060    #[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    /// Scoped macro invocations (`mycrate::my_macro!`). Uses project-named
1087    /// macros so they survive the std-noise filter.
1088    #[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    /// Java `new Foo()` — `object_creation_expression`, NOT method_invocation.
1127    /// Before C-2 the constructor target was silently dropped; only the
1128    /// follow-up `.method()` call was captured.
1129    #[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    /// Java method references (`Foo::bar`). Modern Java + streams uses
1150    /// these heavily; pre-C-3 they emitted no edges because tree-sitter-java
1151    /// models `method_reference` as a distinct AST node from
1152    /// `method_invocation`. Uses non-noise method names so edges survive
1153    /// the std-noise filter (forEach/stream/map/println/toUpperCase are
1154    /// all in is_noise_callee).
1155    #[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    /// v1.11.0 (F1): function-reference callers — a function passed as an
1245    /// argument is a real caller→callee edge. Pre-v1.11.0 these were
1246    /// silently dropped because the tree-sitter call query only matched
1247    /// `call_expression`, not identifiers in argument position. The
1248    /// canonical cliff was the registry pattern in
1249    /// `codelens-mcp/src/tool_defs/build.rs`:
1250    /// `static TOOLS: LazyLock<Vec<Tool>> = LazyLock::new(build_tools);`
1251    /// where `get_callers("build_tools")` returned 0 callers.
1252    ///
1253    /// This test pins the regression by reproducing the same shape: a
1254    /// function used as a function-reference argument to `LazyLock::new`,
1255    /// and a closure-style `iter.map(parse_line)` reference. Both must
1256    /// surface as `<top>` callers (no enclosing fn) for the named
1257    /// callee.
1258    #[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    /// v1.11.1 (F1 follow-up): JS/TS function-reference callbacks. The
1291    /// canonical patterns are `setTimeout(handler, 100)`,
1292    /// `arr.map(parseLine)`, `bus.on("evt", onEvent)`, `.then(success)`.
1293    /// Pre-v1.11.1 these were silently dropped because the JS call
1294    /// query only matched `call_expression`-position function nodes.
1295    #[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    /// v1.11.1: Python function-reference arguments — the
1328    /// `register("evt", handler)` and `dispatcher.on(name, callback)`
1329    /// shapes that callback-heavy Python code uses. Like the JS path,
1330    /// this depends on the resolution cascade filtering variable
1331    /// arguments against the symbol DB.
1332    #[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    /// v1.11.2 (F1 follow-up): Go function-reference arguments. Common
1364    /// in HTTP server registration (`http.HandleFunc("/", handler)`),
1365    /// scheduler dispatch (`time.AfterFunc(d, fn)`), finalizers, and
1366    /// worker pools. Pre-v1.11.2, only the call-expression form was
1367    /// captured; the function-reference form was silently dropped.
1368    #[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    /// v1.11.2 (F1 follow-up): Java function-reference arguments —
1398    /// callbacks passed as bare identifiers (executor submit, listener
1399    /// registration) rather than via the explicit `Class::method`
1400    /// syntax that was already covered.
1401    #[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    /// v1.11.0 (F1): false-positive guard. A bare variable passed as an
1431    /// argument (e.g., `f(local_var)`) is also an `(arguments
1432    /// (identifier))` shape, but `local_var` is not a function in the
1433    /// project symbol DB. The 6-stage resolution cascade should mark it
1434    /// `unresolved` (confidence 0). Without DB access we just verify
1435    /// the extractor doesn't blow up on this shape — resolution is
1436    /// covered by the integration tests in `codelens-mcp` that drive
1437    /// the whole pipeline.
1438    #[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        // Should not panic and should still find the direct call to consume.
1455        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}