Skip to main content

codelens_engine/call_graph/
resolve.rs

1use crate::import_graph::GraphCache;
2use crate::project::{ProjectRoot, collect_files};
3use anyhow::Result;
4use std::collections::{HashMap, HashSet};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use super::js_imports::{JSImportBindingIndex, is_import_sensitive_path};
9use super::language::{best_path_proximity_candidate, call_language_for_path, same_call_language};
10use super::types::CallEdge;
11
12fn symbol_defined_in(
13    symbol_index: &HashMap<String, Vec<String>>,
14    symbol_name: &str,
15    file: &str,
16) -> bool {
17    symbol_index
18        .get(symbol_name)
19        .map(|defs| defs.iter().any(|def| def == file))
20        .unwrap_or(false)
21}
22
23fn resolve_reexport_target(
24    import_bindings: Option<&JSImportBindingIndex>,
25    symbol_index: &HashMap<String, Vec<String>>,
26    resolved_file: &str,
27    canonical_name: &str,
28) -> Option<(String, String)> {
29    let reexport_binding = import_bindings
30        .and_then(|index| index.get(resolved_file))
31        .and_then(|bindings| bindings.get(canonical_name).or_else(|| bindings.get("*")))?;
32    let reexport_file = reexport_binding.resolved_file.as_ref()?;
33    let reexport_name = match reexport_binding.imported_name.as_deref() {
34        Some("*") => canonical_name,
35        Some(name) => name,
36        None => canonical_name,
37    };
38    if symbol_defined_in(symbol_index, reexport_name, reexport_file) {
39        Some((reexport_file.clone(), reexport_name.to_owned()))
40    } else {
41        None
42    }
43}
44
45fn resolve_namespace_reexport_target(
46    import_bindings: Option<&JSImportBindingIndex>,
47    symbol_index: &HashMap<String, Vec<String>>,
48    resolved_file: &str,
49    namespace_name: &str,
50    callee_name: &str,
51) -> Option<(String, String)> {
52    let namespace_binding = import_bindings
53        .and_then(|index| index.get(resolved_file))
54        .and_then(|bindings| bindings.get(namespace_name))?;
55    if namespace_binding.imported_name.as_deref() != Some("*") {
56        return None;
57    }
58    let namespace_file = namespace_binding.resolved_file.as_ref()?;
59    if symbol_defined_in(symbol_index, callee_name, namespace_file) {
60        return Some((namespace_file.clone(), callee_name.to_owned()));
61    }
62    resolve_reexport_target(import_bindings, symbol_index, namespace_file, callee_name)
63}
64
65pub(crate) fn collect_candidate_files(root: &Path) -> Result<Vec<PathBuf>> {
66    collect_files(root, |path| call_language_for_path(path).is_some())
67}
68pub(crate) fn maybe_import_graph(
69    project: &ProjectRoot,
70    files: &[PathBuf],
71    graph_cache: Option<&GraphCache>,
72) -> Option<Arc<HashMap<String, crate::import_graph::FileNode>>> {
73    let cache = graph_cache?;
74    let needs_import_graph = files.iter().any(|file| {
75        let relative = project.to_relative(file);
76        crate::import_graph::supports_import_graph(&relative)
77    });
78    if !needs_import_graph {
79        return None;
80    }
81    let mut graph = crate::import_graph::build_graph_pub(project, cache)
82        .map(|graph| (*graph).clone())
83        .unwrap_or_default();
84
85    for file in files {
86        let relative = project.to_relative(file);
87        if !crate::import_graph::supports_import_graph(&relative) {
88            continue;
89        }
90        let needs_patch = graph
91            .get(&relative)
92            .map(|node| node.imports.is_empty())
93            .unwrap_or(true);
94        if !needs_patch {
95            continue;
96        }
97
98        let imports: HashSet<String> = crate::import_graph::extract_imports_for_file(file)
99            .into_iter()
100            .filter_map(|module| {
101                crate::import_graph::resolve_module_for_file(project, file, &module)
102            })
103            .collect();
104        let entry =
105            graph
106                .entry(relative.clone())
107                .or_insert_with(|| crate::import_graph::FileNode {
108                    imports: HashSet::new(),
109                    imported_by: HashSet::new(),
110                });
111        entry.imports = imports.clone();
112
113        for imported_file in imports {
114            graph
115                .entry(imported_file)
116                .or_insert_with(|| crate::import_graph::FileNode {
117                    imports: HashSet::new(),
118                    imported_by: HashSet::new(),
119                })
120                .imported_by
121                .insert(relative.clone());
122        }
123    }
124
125    if graph.is_empty() {
126        None
127    } else {
128        Some(Arc::new(graph))
129    }
130}
131// ── 6-stage call resolution cascade ──────────────────────────────────────
132
133/// Resolve callee names to their definition files using a 6-stage confidence cascade.
134/// Mutates edges in-place, setting resolved_file, confidence, and resolution_strategy.
135pub(crate) fn resolve_call_edges(
136    edges: &mut [CallEdge],
137    project: &ProjectRoot,
138    import_graph: Option<&HashMap<String, crate::import_graph::FileNode>>,
139    import_bindings: Option<&JSImportBindingIndex>,
140) {
141    // Build a name→files index from the symbol DB for stages 3-5
142    let db_path = crate::db::index_db_path(project.as_path());
143    let symbol_index: HashMap<String, Vec<String>> = crate::db::IndexDb::open(&db_path)
144        .and_then(|db| {
145            let all = db.all_symbol_names()?;
146            let mut map: HashMap<String, Vec<String>> = HashMap::new();
147            for (name, _kind, file, _line, _signature, _name_path) in all {
148                map.entry(name).or_default().push(file);
149            }
150            Ok(map)
151        })
152        .unwrap_or_default();
153
154    for edge in edges.iter_mut() {
155        if edge.confidence > 0.0 {
156            continue; // already resolved
157        }
158
159        let callee = &edge.callee_name;
160        let caller_file = &edge.caller_file;
161        let has_imported_namespace_qualifier = edge
162            .callee_qualifier
163            .as_deref()
164            .and_then(|qualifier| {
165                import_bindings
166                    .and_then(|index| index.get(caller_file))
167                    .and_then(|bindings| bindings.get(qualifier))
168            })
169            .map(|binding| binding.imported_name.as_deref() == Some("*"))
170            .unwrap_or(false);
171
172        // Stage 1: Same file — local definitions beat imported or project-wide matches (0.90)
173        if !has_imported_namespace_qualifier
174            && symbol_defined_in(&symbol_index, callee, caller_file)
175        {
176            edge.resolved_file = Some(caller_file.clone());
177            edge.confidence = 0.90;
178            edge.resolution_strategy = Some("same_file");
179            continue;
180        }
181
182        // Stage 2: Import map — imported target defines the callee (0.95)
183        if let Some(namespace_binding) = edge.callee_qualifier.as_deref().and_then(|qualifier| {
184            import_bindings
185                .and_then(|index| index.get(caller_file))
186                .and_then(|bindings| bindings.get(qualifier))
187        }) && let Some(resolved_file) = namespace_binding.resolved_file.as_ref()
188        {
189            match namespace_binding.imported_name.as_deref() {
190                Some("*") => {
191                    if symbol_defined_in(&symbol_index, callee, resolved_file) {
192                        edge.resolved_file = Some(resolved_file.clone());
193                        edge.confidence = 0.95;
194                        edge.resolution_strategy = Some("import_map");
195                        edge.canonical_callee_name = Some(callee.clone());
196                        continue;
197                    }
198                    if let Some((reexport_file, reexport_name)) = resolve_reexport_target(
199                        import_bindings,
200                        &symbol_index,
201                        resolved_file,
202                        callee,
203                    ) {
204                        edge.resolved_file = Some(reexport_file);
205                        edge.confidence = 0.93;
206                        edge.resolution_strategy = Some("import_reexport_map");
207                        edge.canonical_callee_name = Some(reexport_name);
208                        continue;
209                    }
210                }
211                Some(namespace_name) => {
212                    if let Some((reexport_file, reexport_name)) = resolve_namespace_reexport_target(
213                        import_bindings,
214                        &symbol_index,
215                        resolved_file,
216                        namespace_name,
217                        callee,
218                    ) {
219                        edge.resolved_file = Some(reexport_file);
220                        edge.confidence = 0.93;
221                        edge.resolution_strategy = Some("import_reexport_map");
222                        edge.canonical_callee_name = Some(reexport_name);
223                        continue;
224                    }
225                }
226                None => {}
227            }
228        }
229
230        if let Some(binding) = import_bindings
231            .and_then(|index| index.get(caller_file))
232            .and_then(|bindings| bindings.get(callee))
233            && let Some(resolved_file) = binding.resolved_file.as_ref()
234        {
235            let canonical_name = binding.imported_name.as_deref().unwrap_or(callee);
236            if symbol_defined_in(&symbol_index, canonical_name, resolved_file) {
237                edge.resolved_file = Some(resolved_file.clone());
238                edge.confidence = 0.95;
239                edge.resolution_strategy = Some("import_map");
240                edge.canonical_callee_name = Some(canonical_name.to_owned());
241                continue;
242            }
243            if let Some((reexport_file, reexport_name)) = resolve_reexport_target(
244                import_bindings,
245                &symbol_index,
246                resolved_file,
247                canonical_name,
248            ) {
249                edge.resolved_file = Some(reexport_file);
250                edge.confidence = 0.93;
251                edge.resolution_strategy = Some("import_reexport_map");
252                edge.canonical_callee_name = Some(reexport_name);
253                continue;
254            }
255        }
256
257        if let Some(graph) = import_graph
258            && let Some(node) = graph.get(caller_file)
259        {
260            for imported_file in &node.imports {
261                // Check if imported file defines callee
262                if let Some(defs) = symbol_index.get(callee)
263                    && defs.iter().any(|f| f == imported_file)
264                {
265                    edge.resolved_file = Some(imported_file.clone());
266                    edge.confidence = 0.95;
267                    edge.resolution_strategy = Some("import_map");
268                    edge.canonical_callee_name = Some(callee.clone());
269                    break;
270                }
271            }
272        }
273        if edge.confidence > 0.0 {
274            continue;
275        }
276
277        // Stage 3: Import suffix — imported module suffix points at the callee (0.70)
278        if let Some(graph) = import_graph
279            && let Some(node) = graph.get(caller_file)
280            && let Some(defs) = symbol_index.get(callee)
281        {
282            // Pick the candidate that is also imported (transitively)
283            for def_file in defs {
284                if node.imports.iter().any(|imp| {
285                    // Match on full path suffix, not just filename
286                    def_file.ends_with(imp)
287                        || def_file.ends_with(&format!("/{imp}"))
288                        || imp.ends_with(def_file)
289                        || imp.ends_with(&format!("/{def_file}"))
290                }) {
291                    edge.resolved_file = Some(def_file.clone());
292                    edge.confidence = 0.70;
293                    edge.resolution_strategy = Some("import_suffix");
294                    edge.canonical_callee_name = Some(callee.clone());
295                    break;
296                }
297            }
298        }
299        if edge.confidence > 0.0 {
300            continue;
301        }
302
303        // Stage 4: Unique name — only one same-language definition exists (0.65).
304        // For JS/TS cross-file calls without import evidence, keep this as a fallback.
305        if let Some(defs) = symbol_index.get(callee) {
306            let same_lang_defs: Vec<&String> = defs
307                .iter()
308                .filter(|def| same_call_language(caller_file, def))
309                .collect();
310            if same_lang_defs.len() == 1 {
311                let def = same_lang_defs[0];
312                edge.resolved_file = Some(def.clone());
313                if is_import_sensitive_path(caller_file) && def.as_str() != caller_file.as_str() {
314                    edge.confidence = 0.50;
315                    edge.resolution_strategy = Some("path_proximity");
316                } else {
317                    edge.confidence = 0.65;
318                    edge.resolution_strategy = Some("unique_name");
319                }
320                continue;
321            }
322        }
323
324        // Stage 5: Multiple same-language candidates — pick closest by shared path (0.50).
325        if let Some(defs) = symbol_index.get(callee)
326            && !defs.is_empty()
327            && let Some(best) = best_path_proximity_candidate(caller_file, defs)
328        {
329            edge.resolved_file = Some(best.clone());
330            edge.confidence = 0.50;
331            edge.resolution_strategy = Some("path_proximity");
332            continue;
333        }
334
335        // Stage 6: Unresolved — callee not found in symbol DB (0.25)
336        edge.confidence = 0.25;
337        edge.resolution_strategy = Some("unresolved");
338    }
339}