Skip to main content

grapha_core/
merge.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::extract::ExtractionResult;
4use crate::graph::{EdgeKind, Graph, NodeKind};
5
6struct NameEntry {
7    id: String,
8    module: Option<String>,
9}
10
11pub fn merge(results: Vec<ExtractionResult>) -> Graph {
12    let mut graph = Graph::new();
13
14    let mut file_imports: HashMap<String, HashSet<String>> = HashMap::new();
15    for result in &results {
16        for import in &result.imports {
17            if let Some(first_node) = result.nodes.first() {
18                let file_key = first_node.file.to_string_lossy().to_string();
19                let module_name = import
20                    .path
21                    .strip_prefix("import ")
22                    .unwrap_or(&import.path)
23                    .to_string();
24                file_imports
25                    .entry(file_key)
26                    .or_default()
27                    .insert(module_name);
28            }
29        }
30    }
31
32    for result in &results {
33        graph.nodes.extend(result.nodes.iter().cloned());
34    }
35
36    let node_ids: HashSet<&str> = graph.nodes.iter().map(|node| node.id.as_str()).collect();
37
38    let mut name_to_entries: HashMap<&str, Vec<NameEntry>> = HashMap::new();
39    for node in &graph.nodes {
40        if matches!(node.kind, NodeKind::View | NodeKind::Branch) {
41            continue;
42        }
43        name_to_entries
44            .entry(node.name.as_str())
45            .or_default()
46            .push(NameEntry {
47                id: node.id.clone(),
48                module: node.module.clone(),
49            });
50    }
51
52    let id_to_info: HashMap<&str, (Option<&str>, &str)> = graph
53        .nodes
54        .iter()
55        .map(|node| {
56            (
57                node.id.as_str(),
58                (node.module.as_deref(), node.file.to_str().unwrap_or("")),
59            )
60        })
61        .collect();
62
63    let id_to_name: HashMap<&str, &str> = graph
64        .nodes
65        .iter()
66        .map(|node| (node.id.as_str(), node.name.as_str()))
67        .collect();
68    let mut candidate_to_owner_names: HashMap<String, Vec<String>> = HashMap::new();
69    for result in &results {
70        for edge in &result.edges {
71            if edge.kind == EdgeKind::Contains
72                && let Some(parent_name) = id_to_name.get(edge.source.as_str())
73            {
74                candidate_to_owner_names
75                    .entry(edge.target.clone())
76                    .or_default()
77                    .push(parent_name.to_string());
78            } else if edge.kind == EdgeKind::Implements
79                && let Some(owner_name) = id_to_name.get(edge.target.as_str())
80            {
81                candidate_to_owner_names
82                    .entry(edge.source.clone())
83                    .or_default()
84                    .push(owner_name.to_string());
85            }
86        }
87    }
88
89    let all_edges: Vec<_> = results
90        .into_iter()
91        .flat_map(|result| result.edges)
92        .collect();
93
94    // Build child → parent type mapping for scoping Reads edges.
95    // If source X is contained by type T, reads from X should prefer
96    // targets that are also contained by T (siblings in the same type).
97    let mut child_to_parents: HashMap<String, Vec<String>> = HashMap::new();
98    for edge in &all_edges {
99        if edge.kind == EdgeKind::Contains
100            && node_ids.contains(edge.target.as_str())
101            && node_ids.contains(edge.source.as_str())
102        {
103            child_to_parents
104                .entry(edge.target.clone())
105                .or_default()
106                .push(edge.source.clone());
107        }
108    }
109
110    for mut edge in all_edges {
111        let is_external_usr_call = edge.target.starts_with("s:")
112            && edge.kind == EdgeKind::Calls
113            && !node_ids.contains(edge.target.as_str());
114
115        if node_ids.contains(edge.target.as_str())
116            || edge.kind == EdgeKind::Uses
117            || edge.kind == EdgeKind::Implements
118            || (edge.kind == EdgeKind::Calls
119                && (edge.direction.is_some() || edge.operation.is_some()))
120            || is_external_usr_call
121        {
122            graph.edges.push(edge);
123            continue;
124        }
125
126        let target_name = edge.target.rsplit("::").next().unwrap_or(&edge.target);
127        let Some(candidates) = name_to_entries.get(target_name) else {
128            continue;
129        };
130        if candidates.is_empty() {
131            continue;
132        }
133
134        let (source_module, source_file) = id_to_info
135            .get(edge.source.as_str())
136            .copied()
137            .unwrap_or((None, ""));
138        let source_imports = file_imports.get(source_file);
139        let prefix_hint = edge.operation.as_deref();
140
141        if candidates.len() == 1 {
142            let candidate = &candidates[0];
143            let same_module = modules_match(source_module, candidate.module.as_deref());
144            if same_module {
145                edge.target = candidate.id.clone();
146                edge.confidence *= 0.9;
147                graph.edges.push(edge);
148            } else {
149                let imported = source_imports
150                    .and_then(|imports| {
151                        candidate
152                            .module
153                            .as_deref()
154                            .map(|module| imports.contains(module))
155                    })
156                    .unwrap_or(false);
157                if imported {
158                    edge.target = candidate.id.clone();
159                    edge.confidence *= 0.7;
160                    graph.edges.push(edge);
161                }
162            }
163            continue;
164        }
165
166        // For Reads edges: scope resolution to siblings of the same type.
167        // Without this, "viewModel" resolves to ALL viewModel properties in the module.
168        //
169        // Strategy: use USR prefix matching. If source is s:4Room0A4PageV4bodyQrvp,
170        // its type prefix is s:4Room0A4PageV. Prefer candidates whose ID shares
171        // this prefix (they're members of the same type). Falls back to Contains
172        // edge lookup, then same-file, then normal resolution.
173        if edge.kind == EdgeKind::Reads && candidates.len() > 1 {
174            // Try USR prefix: strip the member suffix to get the type prefix
175            let usr_prefix = if edge.source.starts_with("s:") {
176                usr_type_prefix(&edge.source)
177            } else {
178                None
179            };
180
181            if let Some(prefix) = usr_prefix {
182                let siblings: Vec<&NameEntry> = candidates
183                    .iter()
184                    .filter(|c| c.id.starts_with(&prefix))
185                    .collect();
186                if siblings.len() == 1 {
187                    edge.target = siblings[0].id.clone();
188                    edge.confidence *= 0.9;
189                    graph.edges.push(edge);
190                    continue;
191                }
192                if !siblings.is_empty() {
193                    // Multiple siblings with same prefix — pick same file
194                    let same_file: Vec<&&NameEntry> = siblings
195                        .iter()
196                        .filter(|c| {
197                            id_to_info
198                                .get(c.id.as_str())
199                                .is_some_and(|(_, f)| *f == source_file)
200                        })
201                        .collect();
202                    if same_file.len() == 1 {
203                        edge.target = same_file[0].id.clone();
204                        edge.confidence *= 0.9;
205                        graph.edges.push(edge);
206                        continue;
207                    }
208                }
209                // No siblings found with same USR prefix — this property
210                // is not a member of the source's type. Drop the read edge
211                // rather than resolving to unrelated types.
212                continue;
213            }
214
215            // Fallback: Contains-edge-based sibling matching
216            if let Some(source_owners) = child_to_parents.get(&edge.source) {
217                let sibling_candidates: Vec<&NameEntry> = candidates
218                    .iter()
219                    .filter(|c| {
220                        candidate_to_owner_names.get(&c.id).is_some_and(|owners| {
221                            owners.iter().any(|owner| {
222                                source_owners.iter().any(|so| {
223                                    id_to_name.get(so.as_str()).is_some_and(|n| *n == owner)
224                                })
225                            })
226                        })
227                    })
228                    .collect();
229                if sibling_candidates.len() == 1 {
230                    edge.target = sibling_candidates[0].id.clone();
231                    edge.confidence *= 0.9;
232                    graph.edges.push(edge);
233                    continue;
234                }
235            }
236        }
237
238        let resolved = resolve_candidates(
239            candidates,
240            source_module,
241            source_imports,
242            prefix_hint,
243            &candidate_to_owner_names,
244        );
245        for (candidate_id, factor) in resolved {
246            let mut resolved_edge = edge.clone();
247            resolved_edge.target = candidate_id;
248            resolved_edge.confidence *= factor;
249            graph.edges.push(resolved_edge);
250        }
251    }
252
253    graph
254}
255
256/// Extract the type prefix from a USR string.
257/// e.g., "s:4Room0A4PageV4bodyQrvp" → "s:4Room0A4PageV"
258/// USR structure: s:<module><type>V<member> where V marks the type boundary.
259fn usr_type_prefix(usr: &str) -> Option<String> {
260    // Find the last 'V' that's followed by lowercase (member name start)
261    // Swift USRs use V to end type names: s:4Room0A4PageV4bodyQrvp
262    //                                                    ^ type ends here
263    let bytes = usr.as_bytes();
264    let mut last_v_pos = None;
265    for i in (2..bytes.len()).rev() {
266        if bytes[i] == b'V'
267            && i + 1 < bytes.len()
268            && (bytes[i + 1].is_ascii_digit() || bytes[i + 1].is_ascii_lowercase())
269        {
270            last_v_pos = Some(i + 1);
271            break;
272        }
273    }
274    last_v_pos.map(|pos| usr[..pos].to_string())
275}
276
277fn resolve_candidates(
278    candidates: &[NameEntry],
279    source_module: Option<&str>,
280    source_imports: Option<&HashSet<String>>,
281    prefix_hint: Option<&str>,
282    candidate_to_owner_names: &HashMap<String, Vec<String>>,
283) -> Vec<(String, f64)> {
284    let same_module: Vec<&NameEntry> = candidates
285        .iter()
286        .filter(|candidate| modules_match(source_module, candidate.module.as_deref()))
287        .collect();
288    if same_module.len() == 1 {
289        return vec![(same_module[0].id.clone(), 0.9)];
290    }
291
292    if same_module.len() > 1 {
293        if let Some(hint) = prefix_hint {
294            let hint_name = hint.rsplit('.').next().unwrap_or(hint).to_lowercase();
295            let exact: Vec<&&NameEntry> = same_module
296                .iter()
297                .filter(|candidate| {
298                    candidate_to_owner_names
299                        .get(&candidate.id)
300                        .is_some_and(|parents| {
301                            parents
302                                .iter()
303                                .any(|parent| parent.eq_ignore_ascii_case(&hint_name))
304                        })
305                })
306                .collect();
307            if exact.len() == 1 {
308                return vec![(exact[0].id.clone(), 0.85)];
309            }
310
311            let narrowed: Vec<&&NameEntry> = same_module
312                .iter()
313                .filter(|candidate| {
314                    candidate_to_owner_names
315                        .get(&candidate.id)
316                        .is_some_and(|parents| {
317                            parents
318                                .iter()
319                                .any(|parent| parent.to_lowercase().contains(&hint_name))
320                        })
321                })
322                .collect();
323            if narrowed.len() == 1 {
324                return vec![(narrowed[0].id.clone(), 0.85)];
325            }
326        }
327
328        return same_module
329            .iter()
330            .map(|candidate| (candidate.id.clone(), 0.4))
331            .collect();
332    }
333
334    if let Some(imports) = source_imports {
335        let imported: Vec<&NameEntry> = candidates
336            .iter()
337            .filter(|candidate| {
338                candidate
339                    .module
340                    .as_deref()
341                    .is_some_and(|module| imports.contains(module))
342            })
343            .collect();
344        if imported.len() == 1 {
345            return vec![(imported[0].id.clone(), 0.8)];
346        }
347        if imported.len() > 1 {
348            return imported
349                .iter()
350                .map(|candidate| (candidate.id.clone(), 0.3))
351                .collect();
352        }
353    }
354
355    Vec::new()
356}
357
358fn modules_match(left: Option<&str>, right: Option<&str>) -> bool {
359    match (left, right) {
360        (Some(left), Some(right)) => left == right,
361        (None, None) => true,
362        _ => false,
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::graph::*;
370    use std::collections::HashMap;
371    use std::path::PathBuf;
372
373    fn make_node(id: &str, name: &str, kind: NodeKind) -> Node {
374        Node {
375            id: id.to_string(),
376            kind,
377            name: name.to_string(),
378            file: PathBuf::from("test.rs"),
379            span: Span {
380                start: [0, 0],
381                end: [0, 0],
382            },
383            visibility: Visibility::Public,
384            metadata: HashMap::new(),
385            role: None,
386            signature: None,
387            doc_comment: None,
388            module: None,
389            snippet: None,
390        }
391    }
392
393    #[test]
394    fn merges_nodes_from_multiple_results() {
395        let left = ExtractionResult {
396            nodes: vec![make_node("a::Foo", "Foo", NodeKind::Struct)],
397            edges: vec![],
398            imports: vec![],
399        };
400        let right = ExtractionResult {
401            nodes: vec![make_node("b::Bar", "Bar", NodeKind::Struct)],
402            edges: vec![],
403            imports: vec![],
404        };
405
406        let graph = merge(vec![left, right]);
407        assert_eq!(graph.nodes.len(), 2);
408    }
409
410    #[test]
411    fn drops_edges_with_unresolved_targets() {
412        let result = ExtractionResult {
413            nodes: vec![make_node("a::main", "main", NodeKind::Function)],
414            edges: vec![Edge {
415                source: "a::main".to_string(),
416                target: "nonexistent::foo".to_string(),
417                kind: EdgeKind::Calls,
418                confidence: 1.0,
419                direction: None,
420                operation: None,
421                condition: None,
422                async_boundary: None,
423                provenance: Vec::new(),
424            }],
425            imports: vec![],
426        };
427
428        let graph = merge(vec![result]);
429        assert_eq!(graph.edges.len(), 0);
430    }
431
432    #[test]
433    fn keeps_uses_edges_even_if_target_unresolved() {
434        let result = ExtractionResult {
435            nodes: vec![],
436            edges: vec![Edge {
437                source: "a.rs".to_string(),
438                target: "use std::collections::HashMap;".to_string(),
439                kind: EdgeKind::Uses,
440                confidence: 1.0,
441                direction: None,
442                operation: None,
443                condition: None,
444                async_boundary: None,
445                provenance: Vec::new(),
446            }],
447            imports: vec![],
448        };
449
450        let graph = merge(vec![result]);
451        assert_eq!(graph.edges.len(), 1);
452    }
453
454    #[test]
455    fn same_module_candidate_wins() {
456        let mut helper = make_node("mod_a::helper", "helper", NodeKind::Function);
457        helper.module = Some("mod_a".to_string());
458        let mut caller = make_node("mod_a::main", "main", NodeKind::Function);
459        caller.module = Some("mod_a".to_string());
460
461        let graph = merge(vec![
462            ExtractionResult {
463                nodes: vec![caller],
464                edges: vec![Edge {
465                    source: "mod_a::main".to_string(),
466                    target: "unknown::helper".to_string(),
467                    kind: EdgeKind::Calls,
468                    confidence: 1.0,
469                    direction: None,
470                    operation: None,
471                    condition: None,
472                    async_boundary: None,
473                    provenance: Vec::new(),
474                }],
475                imports: vec![],
476            },
477            ExtractionResult {
478                nodes: vec![helper],
479                edges: vec![],
480                imports: vec![],
481            },
482        ]);
483
484        let call_edge = graph
485            .edges
486            .iter()
487            .find(|edge| edge.kind == EdgeKind::Calls)
488            .unwrap();
489        assert_eq!(call_edge.target, "mod_a::helper");
490        assert!((call_edge.confidence - 0.9).abs() < 0.001);
491    }
492
493    #[test]
494    fn owner_hint_disambiguates_same_module_candidates() {
495        let mut room_page_ext = make_node("room::RoomPageExt", "RoomPage", NodeKind::Extension);
496        room_page_ext.module = Some("Room".to_string());
497        let mut kroom_page_ext = make_node("room::KRoomPageExt", "KRoomPage", NodeKind::Extension);
498        kroom_page_ext.module = Some("Room".to_string());
499
500        let mut room_helper = make_node(
501            "room::RoomPage::chatRoomFragViewPanel",
502            "chatRoomFragViewPanel",
503            NodeKind::Property,
504        );
505        room_helper.module = Some("Room".to_string());
506        let mut kroom_helper = make_node(
507            "room::KRoomPage::chatRoomFragViewPanel",
508            "chatRoomFragViewPanel",
509            NodeKind::Property,
510        );
511        kroom_helper.module = Some("Room".to_string());
512
513        let mut body_view = make_node(
514            "room::RoomPage::body::view:chatRoomFragViewPanel",
515            "chatRoomFragViewPanel",
516            NodeKind::View,
517        );
518        body_view.module = Some("Room".to_string());
519
520        let source = body_view.id.clone();
521        let graph = merge(vec![
522            ExtractionResult {
523                nodes: vec![body_view],
524                edges: vec![Edge {
525                    source: source.clone(),
526                    target: "room::RoomPage.swift::chatRoomFragViewPanel".to_string(),
527                    kind: EdgeKind::TypeRef,
528                    confidence: 1.0,
529                    direction: None,
530                    operation: Some("RoomPage".to_string()),
531                    condition: None,
532                    async_boundary: None,
533                    provenance: Vec::new(),
534                }],
535                imports: vec![],
536            },
537            ExtractionResult {
538                nodes: vec![room_page_ext, kroom_page_ext, room_helper, kroom_helper],
539                edges: vec![
540                    Edge {
541                        source: "room::RoomPage::chatRoomFragViewPanel".to_string(),
542                        target: "room::RoomPageExt".to_string(),
543                        kind: EdgeKind::Implements,
544                        confidence: 1.0,
545                        direction: None,
546                        operation: None,
547                        condition: None,
548                        async_boundary: None,
549                        provenance: Vec::new(),
550                    },
551                    Edge {
552                        source: "room::KRoomPage::chatRoomFragViewPanel".to_string(),
553                        target: "room::KRoomPageExt".to_string(),
554                        kind: EdgeKind::Implements,
555                        confidence: 1.0,
556                        direction: None,
557                        operation: None,
558                        condition: None,
559                        async_boundary: None,
560                        provenance: Vec::new(),
561                    },
562                ],
563                imports: vec![],
564            },
565        ]);
566
567        let type_refs: Vec<_> = graph
568            .edges
569            .iter()
570            .filter(|edge| edge.source == source && edge.kind == EdgeKind::TypeRef)
571            .collect();
572
573        assert_eq!(type_refs.len(), 1);
574        assert_eq!(type_refs[0].target, "room::RoomPage::chatRoomFragViewPanel");
575        assert!((type_refs[0].confidence - 0.85).abs() < 0.001);
576    }
577}