Skip to main content

codesynapse_core/
symbol_resolution.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::types::{Edge, Node};
4
5pub fn normalise_callable_label(label: &str) -> String {
6    label
7        .trim()
8        .trim_matches(|c| c == '(' || c == ')')
9        .trim_start_matches('.')
10        .to_lowercase()
11}
12
13pub fn node_is_resolvable_symbol(node: &Node) -> bool {
14    if node.file_type != "code" {
15        return false;
16    }
17    let label = node.label.trim();
18    if label.is_empty() {
19        return false;
20    }
21    for ext in &[".py", ".js", ".ts", ".tsx", ".java", ".go", ".rs"] {
22        if label.ends_with(ext) {
23            return false;
24        }
25    }
26    !normalise_callable_label(label).is_empty()
27}
28
29pub fn build_label_index(nodes: &[Node]) -> HashMap<String, Vec<String>> {
30    let mut index: HashMap<String, Vec<String>> = HashMap::new();
31    for node in nodes {
32        if !node_is_resolvable_symbol(node) {
33            continue;
34        }
35        let key = normalise_callable_label(&node.label);
36        if key.is_empty() {
37            continue;
38        }
39        index.entry(key).or_default().push(node.id.clone());
40    }
41    index
42}
43
44pub fn existing_edge_pairs(edges: &[Edge]) -> HashSet<(String, String, String)> {
45    edges
46        .iter()
47        .map(|e| (e.source.clone(), e.target.clone(), e.relation.clone()))
48        .collect()
49}
50
51pub fn resolve_cross_file_raw_calls(
52    per_file: &[Option<RawCallsFragment>],
53    all_nodes: &[Node],
54    all_edges: &[Edge],
55) -> Vec<Edge> {
56    let label_index = build_label_index(all_nodes);
57    let mut known_pairs = existing_edge_pairs(all_edges);
58    let mut resolved: Vec<Edge> = Vec::new();
59
60    for fragment_opt in per_file {
61        let fragment = match fragment_opt {
62            Some(f) => f,
63            None => continue,
64        };
65        for raw_call in &fragment.raw_calls {
66            let callee = raw_call.callee.trim();
67            if callee.is_empty() {
68                continue;
69            }
70            if raw_call.is_member_call {
71                continue;
72            }
73            let candidates = label_index.get(&callee.to_lowercase());
74            let candidates = match candidates {
75                Some(c) if c.len() == 1 => c,
76                _ => continue,
77            };
78            let target = &candidates[0];
79            let caller = raw_call.caller_nid.trim();
80            if caller.is_empty() || caller == target.as_str() {
81                continue;
82            }
83            let pair = (caller.to_string(), target.clone(), "calls".to_string());
84            if known_pairs.contains(&pair) {
85                continue;
86            }
87            known_pairs.insert(pair);
88            resolved.push(Edge {
89                source: caller.to_string(),
90                target: target.clone(),
91                relation: "calls".to_string(),
92                confidence: "INFERRED".to_string(),
93                source_file: if raw_call.source_file.is_empty() {
94                    None
95                } else {
96                    Some(raw_call.source_file.clone())
97                },
98                weight: 1.0,
99                context: None,
100            });
101        }
102    }
103    resolved
104}
105
106#[derive(Debug, Clone, Default)]
107pub struct RawCall {
108    pub callee: String,
109    pub caller_nid: String,
110    pub is_member_call: bool,
111    pub source_file: String,
112    pub source_location: String,
113}
114
115#[derive(Debug, Clone, Default)]
116pub struct RawCallsFragment {
117    pub raw_calls: Vec<RawCall>,
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use std::collections::HashMap;
124
125    fn make_code_node(id: &str, label: &str) -> Node {
126        Node {
127            id: id.to_string(),
128            label: label.to_string(),
129            file_type: "code".to_string(),
130            source_file: "test.py".to_string(),
131            source_location: None,
132            community: None,
133            rationale: None,
134            docstring: None,
135            metadata: HashMap::new(),
136        }
137    }
138
139    fn make_node_with_file(id: &str, label: &str, file: &str) -> Node {
140        Node {
141            id: id.to_string(),
142            label: label.to_string(),
143            file_type: "code".to_string(),
144            source_file: file.to_string(),
145            source_location: None,
146            community: None,
147            rationale: None,
148            docstring: None,
149            metadata: HashMap::new(),
150        }
151    }
152
153    fn make_doc_node(id: &str, label: &str) -> Node {
154        Node {
155            id: id.to_string(),
156            label: label.to_string(),
157            file_type: "document".to_string(),
158            source_file: "test.md".to_string(),
159            source_location: None,
160            community: None,
161            rationale: None,
162            docstring: None,
163            metadata: HashMap::new(),
164        }
165    }
166
167    fn make_edge(src: &str, tgt: &str, rel: &str) -> Edge {
168        Edge {
169            source: src.to_string(),
170            target: tgt.to_string(),
171            relation: rel.to_string(),
172            confidence: "EXTRACTED".to_string(),
173            source_file: None,
174            weight: 1.0,
175            context: None,
176        }
177    }
178
179    fn raw_call(callee: &str, caller_nid: &str) -> RawCall {
180        RawCall {
181            callee: callee.to_string(),
182            caller_nid: caller_nid.to_string(),
183            is_member_call: false,
184            source_file: "test.py".to_string(),
185            source_location: String::new(),
186        }
187    }
188
189    fn member_call(callee: &str, caller_nid: &str) -> RawCall {
190        RawCall {
191            callee: callee.to_string(),
192            caller_nid: caller_nid.to_string(),
193            is_member_call: true,
194            source_file: "test.py".to_string(),
195            source_location: String::new(),
196        }
197    }
198
199    // --- normalise_callable_label ---
200
201    #[test]
202    fn test_normalise_strips_parens() {
203        assert_eq!(normalise_callable_label("foo()"), "foo");
204    }
205
206    #[test]
207    fn test_normalise_lowercase() {
208        assert_eq!(normalise_callable_label("MyFunc"), "myfunc");
209    }
210
211    #[test]
212    fn test_normalise_strips_leading_dot() {
213        assert_eq!(normalise_callable_label(".foo"), "foo");
214    }
215
216    #[test]
217    fn test_normalise_strips_whitespace() {
218        assert_eq!(normalise_callable_label("  bar  "), "bar");
219    }
220
221    #[test]
222    fn test_normalise_empty() {
223        assert_eq!(normalise_callable_label(""), "");
224    }
225
226    // --- node_is_resolvable_symbol ---
227
228    #[test]
229    fn test_code_node_is_resolvable() {
230        let node = make_code_node("n1", "my_fn");
231        assert!(node_is_resolvable_symbol(&node));
232    }
233
234    #[test]
235    fn test_document_node_not_resolvable() {
236        let node = make_doc_node("n1", "my_fn");
237        assert!(!node_is_resolvable_symbol(&node));
238    }
239
240    #[test]
241    fn test_empty_label_not_resolvable() {
242        let node = make_code_node("n1", "");
243        assert!(!node_is_resolvable_symbol(&node));
244    }
245
246    #[test]
247    fn test_py_extension_label_not_resolvable() {
248        let node = make_code_node("n1", "module.py");
249        assert!(!node_is_resolvable_symbol(&node));
250    }
251
252    #[test]
253    fn test_js_extension_label_not_resolvable() {
254        let node = make_code_node("n1", "module.js");
255        assert!(!node_is_resolvable_symbol(&node));
256    }
257
258    #[test]
259    fn test_ts_extension_label_not_resolvable() {
260        let node = make_code_node("n1", "module.ts");
261        assert!(!node_is_resolvable_symbol(&node));
262    }
263
264    #[test]
265    fn test_tsx_extension_label_not_resolvable() {
266        let node = make_code_node("n1", "component.tsx");
267        assert!(!node_is_resolvable_symbol(&node));
268    }
269
270    #[test]
271    fn test_java_extension_label_not_resolvable() {
272        let node = make_code_node("n1", "MyClass.java");
273        assert!(!node_is_resolvable_symbol(&node));
274    }
275
276    #[test]
277    fn test_go_extension_label_not_resolvable() {
278        let node = make_code_node("n1", "main.go");
279        assert!(!node_is_resolvable_symbol(&node));
280    }
281
282    #[test]
283    fn test_rs_extension_label_not_resolvable() {
284        let node = make_code_node("n1", "lib.rs");
285        assert!(!node_is_resolvable_symbol(&node));
286    }
287
288    // --- build_label_index ---
289
290    #[test]
291    fn test_label_index_basic() {
292        let nodes = vec![make_code_node("n1", "my_fn")];
293        let index = build_label_index(&nodes);
294        assert!(index.contains_key("my_fn"));
295        assert_eq!(index["my_fn"], vec!["n1"]);
296    }
297
298    #[test]
299    fn test_label_index_case_insensitive() {
300        let nodes = vec![make_code_node("n1", "MyFn")];
301        let index = build_label_index(&nodes);
302        assert!(index.contains_key("myfn"));
303    }
304
305    #[test]
306    fn test_label_index_multiple_nodes_same_label() {
307        let nodes = vec![
308            make_node_with_file("n1", "fn_a", "a.py"),
309            make_node_with_file("n2", "fn_a", "b.py"),
310        ];
311        let index = build_label_index(&nodes);
312        assert_eq!(index["fn_a"].len(), 2);
313    }
314
315    #[test]
316    fn test_label_index_excludes_doc_nodes() {
317        let nodes = vec![make_doc_node("n1", "my_fn")];
318        let index = build_label_index(&nodes);
319        assert!(index.is_empty());
320    }
321
322    #[test]
323    fn test_label_index_empty_nodes() {
324        let index = build_label_index(&[]);
325        assert!(index.is_empty());
326    }
327
328    // --- existing_edge_pairs ---
329
330    #[test]
331    fn test_existing_edge_pairs_basic() {
332        let edges = vec![make_edge("a", "b", "calls")];
333        let pairs = existing_edge_pairs(&edges);
334        assert!(pairs.contains(&("a".to_string(), "b".to_string(), "calls".to_string())));
335    }
336
337    #[test]
338    fn test_existing_edge_pairs_relation_distinguishes() {
339        let edges = vec![
340            make_edge("a", "b", "calls"),
341            make_edge("a", "b", "contains"),
342        ];
343        let pairs = existing_edge_pairs(&edges);
344        assert_eq!(pairs.len(), 2);
345    }
346
347    #[test]
348    fn test_existing_edge_pairs_empty() {
349        let pairs = existing_edge_pairs(&[]);
350        assert!(pairs.is_empty());
351    }
352
353    // --- resolve_cross_file_raw_calls ---
354
355    #[test]
356    fn test_resolve_unique_callee() {
357        let nodes = vec![make_code_node("target_id", "helper")];
358        let per_file = vec![Some(RawCallsFragment {
359            raw_calls: vec![raw_call("helper", "caller_id")],
360        })];
361        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
362        assert_eq!(resolved.len(), 1);
363        assert_eq!(resolved[0].source, "caller_id");
364        assert_eq!(resolved[0].target, "target_id");
365        assert_eq!(resolved[0].relation, "calls");
366        assert_eq!(resolved[0].confidence, "INFERRED");
367    }
368
369    #[test]
370    fn test_resolve_ambiguous_callee_skipped() {
371        let nodes = vec![
372            make_node_with_file("t1", "helper", "a.py"),
373            make_node_with_file("t2", "helper", "b.py"),
374        ];
375        let per_file = vec![Some(RawCallsFragment {
376            raw_calls: vec![raw_call("helper", "caller_id")],
377        })];
378        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
379        assert_eq!(resolved.len(), 0);
380    }
381
382    #[test]
383    fn test_resolve_member_call_skipped() {
384        let nodes = vec![make_code_node("target_id", "do_thing")];
385        let per_file = vec![Some(RawCallsFragment {
386            raw_calls: vec![member_call("do_thing", "caller_id")],
387        })];
388        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
389        assert_eq!(resolved.len(), 0);
390    }
391
392    #[test]
393    fn test_resolve_empty_callee_skipped() {
394        let nodes = vec![make_code_node("target_id", "fn")];
395        let per_file = vec![Some(RawCallsFragment {
396            raw_calls: vec![raw_call("", "caller_id")],
397        })];
398        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
399        assert_eq!(resolved.len(), 0);
400    }
401
402    #[test]
403    fn test_resolve_self_call_skipped() {
404        let nodes = vec![make_code_node("caller_id", "my_fn")];
405        let per_file = vec![Some(RawCallsFragment {
406            raw_calls: vec![raw_call("my_fn", "caller_id")],
407        })];
408        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
409        assert_eq!(resolved.len(), 0);
410    }
411
412    #[test]
413    fn test_resolve_existing_edge_not_duplicated() {
414        let nodes = vec![make_code_node("target_id", "helper")];
415        let existing = vec![make_edge("caller_id", "target_id", "calls")];
416        let per_file = vec![Some(RawCallsFragment {
417            raw_calls: vec![raw_call("helper", "caller_id")],
418        })];
419        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &existing);
420        assert_eq!(resolved.len(), 0);
421    }
422
423    #[test]
424    fn test_resolve_none_fragment_skipped() {
425        let nodes = vec![make_code_node("target_id", "helper")];
426        let per_file: Vec<Option<RawCallsFragment>> = vec![None];
427        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
428        assert_eq!(resolved.len(), 0);
429    }
430
431    #[test]
432    fn test_resolve_multiple_calls_deduped() {
433        let nodes = vec![make_code_node("target_id", "helper")];
434        let per_file = vec![Some(RawCallsFragment {
435            raw_calls: vec![
436                raw_call("helper", "caller_id"),
437                raw_call("helper", "caller_id"),
438            ],
439        })];
440        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
441        assert_eq!(resolved.len(), 1);
442    }
443
444    #[test]
445    fn test_resolve_case_insensitive_callee() {
446        let nodes = vec![make_code_node("target_id", "MyHelper")];
447        let per_file = vec![Some(RawCallsFragment {
448            raw_calls: vec![raw_call("myhelper", "caller_id")],
449        })];
450        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
451        assert_eq!(resolved.len(), 1);
452    }
453
454    #[test]
455    fn test_resolve_distinct_relation_does_not_suppress() {
456        // A "contains" edge between same endpoints should not suppress a "calls" edge
457        let nodes = vec![make_code_node("target_id", "helper")];
458        let existing = vec![make_edge("caller_id", "target_id", "contains")];
459        let per_file = vec![Some(RawCallsFragment {
460            raw_calls: vec![raw_call("helper", "caller_id")],
461        })];
462        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &existing);
463        assert_eq!(resolved.len(), 1);
464    }
465
466    #[test]
467    fn test_resolve_no_nodes_no_edges() {
468        let per_file = vec![Some(RawCallsFragment {
469            raw_calls: vec![raw_call("helper", "caller_id")],
470        })];
471        let resolved = resolve_cross_file_raw_calls(&per_file, &[], &[]);
472        assert_eq!(resolved.len(), 0);
473    }
474
475    #[test]
476    fn test_resolve_empty_caller_nid_skipped() {
477        let nodes = vec![make_code_node("target_id", "helper")];
478        let per_file = vec![Some(RawCallsFragment {
479            raw_calls: vec![raw_call("helper", "")],
480        })];
481        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
482        assert_eq!(resolved.len(), 0);
483    }
484
485    #[test]
486    fn test_resolve_edge_weight_is_one() {
487        let nodes = vec![make_code_node("target_id", "helper")];
488        let per_file = vec![Some(RawCallsFragment {
489            raw_calls: vec![raw_call("helper", "caller_id")],
490        })];
491        let resolved = resolve_cross_file_raw_calls(&per_file, &nodes, &[]);
492        assert_eq!(resolved[0].weight, 1.0);
493    }
494}