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 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 if edge.kind == EdgeKind::Reads && candidates.len() > 1 {
174 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 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 continue;
213 }
214
215 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
256fn usr_type_prefix(usr: &str) -> Option<String> {
260 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}