1use crate::parser::{ImportInfo, ParseResult};
8use crate::schema::{CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind};
9use std::collections::HashMap;
10
11#[derive(Debug, Default)]
13pub struct NameResolver {
14 name_to_id: HashMap<String, String>,
16 short_name_to_id: HashMap<String, String>,
18}
19
20impl NameResolver {
21 pub fn from_nodes(nodes: &[CodeNode]) -> Self {
23 let mut resolver = Self::default();
24 for node in nodes {
25 resolver.name_to_id.insert(node.id.clone(), node.id.clone());
27
28 let qualified = node_to_qualified_name(node);
30 if !qualified.is_empty() {
31 resolver.name_to_id.insert(qualified, node.id.clone());
32 }
33
34 if !node.name.is_empty() {
36 resolver
37 .short_name_to_id
38 .entry(node.name.clone())
39 .or_insert_with(|| node.id.clone());
40 }
41 }
42 resolver
43 }
44
45 pub fn resolve(&self, name: &str) -> Option<&String> {
47 self.name_to_id
48 .get(name)
49 .or_else(|| self.short_name_to_id.get(name))
50 }
51}
52
53pub fn extract_edges(parse_results: &[ParseResult], resolver: &NameResolver) -> Vec<CodeEdge> {
61 let mut edges = Vec::new();
62
63 let all_nodes: Vec<&CodeNode> = parse_results.iter().flat_map(|r| &r.nodes).collect();
65
66 for node in &all_nodes {
68 if let Some(parent_id) = &node.parent_id {
69 edges.push(CodeEdge {
70 source_id: parent_id.clone(),
71 target_id: node.id.clone(),
72 predicate: CodeEdgePredicate::Contains,
73 weight: None,
74 commit_id: None,
75 });
76 }
77 }
78
79 let all_imports: Vec<&ImportInfo> = parse_results.iter().flat_map(|r| &r.imports).collect();
81
82 for imp in &all_imports {
83 let target = resolve_import(imp, resolver);
85 let target_id = target.unwrap_or_else(|| format!("ext:{}", imp.module));
86
87 edges.push(CodeEdge {
88 source_id: imp.file_node_id.clone(),
89 target_id,
90 predicate: CodeEdgePredicate::Imports,
91 weight: None,
92 commit_id: None,
93 });
94 }
95
96 for node in &all_nodes {
98 if node.kind == CodeNodeKind::Class
99 && let Some(sig) = &node.signature
100 {
101 let bases = extract_bases_from_signature(sig);
102 for base in bases {
103 let target_id = resolver
104 .resolve(&base)
105 .cloned()
106 .unwrap_or_else(|| format!("ext:{base}"));
107 edges.push(CodeEdge {
108 source_id: node.id.clone(),
109 target_id,
110 predicate: CodeEdgePredicate::InheritsFrom,
111 weight: None,
112 commit_id: None,
113 });
114 }
115 }
116 }
117
118 edges
119}
120
121pub fn extract_call_edges(
127 parse_results: &[ParseResult],
128 _resolver: &NameResolver,
129 source_texts: &HashMap<String, String>,
130) -> Vec<CodeEdge> {
131 let mut edges = Vec::new();
132
133 let all_nodes: Vec<&CodeNode> = parse_results.iter().flat_map(|r| &r.nodes).collect();
134
135 let callable_names: HashMap<&str, &str> = all_nodes
137 .iter()
138 .filter(|n| {
139 matches!(
140 n.kind,
141 CodeNodeKind::Function | CodeNodeKind::Method | CodeNodeKind::Test
142 )
143 })
144 .map(|n| (n.name.as_str(), n.id.as_str()))
145 .collect();
146
147 for node in &all_nodes {
149 if !matches!(
150 node.kind,
151 CodeNodeKind::Function | CodeNodeKind::Method | CodeNodeKind::Test
152 ) {
153 continue;
154 }
155
156 let file_path = node_id_to_path(&node.id);
158 let Some(source) = source_texts.get(&file_path) else {
159 continue;
160 };
161
162 for (name, target_id) in &callable_names {
165 if *target_id == node.id {
166 continue; }
168
169 let call_pattern = format!("{name}(");
170 let count = source.matches(&call_pattern).count();
172 if count > 0 {
173 let valid = source.contains(&format!(".{call_pattern}"))
176 || source.contains(&format!(" {call_pattern}"))
177 || source.contains(&format!("={call_pattern}"))
178 || source.contains(&format!("({call_pattern}"))
179 || source.starts_with(&call_pattern);
180
181 if valid {
182 edges.push(CodeEdge {
183 source_id: node.id.clone(),
184 target_id: target_id.to_string(),
185 predicate: CodeEdgePredicate::Calls,
186 weight: Some(count as f32),
187 commit_id: None,
188 });
189 }
190 }
191 }
192 }
193
194 edges
195}
196
197fn node_to_qualified_name(node: &CodeNode) -> String {
201 let id = &node.id;
203 let after_colon = id.split_once(':').map(|(_, rest)| rest).unwrap_or(id);
204 let (path, name) = after_colon.split_once("::").unwrap_or((after_colon, ""));
205
206 let mod_path = path.strip_suffix(".py").unwrap_or(path).replace('/', ".");
207
208 if name.is_empty() {
209 mod_path
210 } else if name.contains("::") {
211 format!("{mod_path}.{}", name.replace("::", "."))
213 } else {
214 format!("{mod_path}.{name}")
215 }
216}
217
218fn node_id_to_path(id: &str) -> String {
220 let after_colon = id.split_once(':').map(|(_, rest)| rest).unwrap_or(id);
221 after_colon
222 .split_once("::")
223 .map(|(path, _)| path.to_string())
224 .unwrap_or_else(|| after_colon.to_string())
225}
226
227fn resolve_import(imp: &ImportInfo, resolver: &NameResolver) -> Option<String> {
229 let mod_id = format!("mod:{}.py", imp.module.replace('.', "/"));
231 if let Some(id) = resolver.resolve(&mod_id) {
232 return Some(id.clone());
233 }
234
235 let file_id = format!("file:{}.py", imp.module.replace('.', "/"));
237 if let Some(id) = resolver.resolve(&file_id) {
238 return Some(id.clone());
239 }
240
241 resolver.resolve(&imp.module).cloned()
243}
244
245fn extract_bases_from_signature(sig: &str) -> Vec<String> {
247 let Some(start) = sig.find('(') else {
248 return Vec::new();
249 };
250 let Some(end) = sig.rfind(')') else {
251 return Vec::new();
252 };
253 if start >= end {
254 return Vec::new();
255 }
256
257 sig[start + 1..end]
258 .split(',')
259 .map(|s| s.trim().to_string())
260 .filter(|s| !s.is_empty())
261 .collect()
262}
263
264pub fn extract_cross_file_edges(
272 parse_results: &[ParseResult],
273 resolver: &crate::module_resolver::RustModuleResolver,
274) -> Vec<CodeEdge> {
275 let mut edges = Vec::new();
276
277 let all_nodes: Vec<&CodeNode> = parse_results.iter().flat_map(|r| &r.nodes).collect();
278
279 let all_imports: Vec<&ImportInfo> = parse_results.iter().flat_map(|r| &r.imports).collect();
281 for imp in &all_imports {
282 let resolved = resolver.resolve_use(&imp.module, &imp.names);
283 for (name, target_id) in &resolved {
284 edges.push(CodeEdge {
285 source_id: imp.file_node_id.clone(),
286 target_id: target_id.clone(),
287 predicate: CodeEdgePredicate::Imports,
288 weight: None,
289 commit_id: None,
290 });
291 let use_node = all_nodes.iter().find(|n| {
294 n.kind == CodeNodeKind::RustUse
295 && n.name == *name
296 && n.parent_id.as_deref() == Some(&imp.file_node_id)
297 });
298 if let Some(use_node) = use_node
299 && use_node
300 .signature
301 .as_ref()
302 .is_some_and(|s| s.starts_with("pub"))
303 {
304 edges.push(CodeEdge {
305 source_id: imp.file_node_id.clone(),
306 target_id: target_id.clone(),
307 predicate: CodeEdgePredicate::ReExports,
308 weight: None,
309 commit_id: None,
310 });
311 }
312 }
313 }
314
315 for node in &all_nodes {
317 if node.kind != CodeNodeKind::RustImpl {
318 continue;
319 }
320 if let Some(sig) = &node.signature
322 && let Some(trait_name) = extract_trait_from_impl(sig)
323 {
324 let target_id = resolver
325 .resolve_name(&trait_name)
326 .unwrap_or_else(|| format!("ext:{trait_name}"));
327 edges.push(CodeEdge {
328 source_id: node.id.clone(),
329 target_id,
330 predicate: CodeEdgePredicate::ImplementsTrait,
331 weight: None,
332 commit_id: None,
333 });
334 }
335 }
336
337 for node in &all_nodes {
339 if node.kind != CodeNodeKind::RustTest && node.kind != CodeNodeKind::Test {
340 continue;
341 }
342 if let Some(target_name) = node.name.strip_prefix("test_")
343 && let Some(target_id) = resolver.resolve_name(target_name)
344 {
345 edges.push(CodeEdge {
346 source_id: node.id.clone(),
347 target_id,
348 predicate: CodeEdgePredicate::TestTargets,
349 weight: None,
350 commit_id: None,
351 });
352 }
353 }
354
355 edges
356}
357
358fn extract_trait_from_impl(sig: &str) -> Option<String> {
363 let sig = sig.strip_prefix("impl").map(|s| s.trim())?;
364 if let Some(for_idx) = sig.find(" for ") {
365 let trait_part = sig[..for_idx].trim();
366 let name = trait_part
368 .split('<')
369 .next()
370 .unwrap_or(trait_part)
371 .trim()
372 .to_string();
373 if name.is_empty() { None } else { Some(name) }
374 } else {
375 None }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use crate::parser::parse_python_file;
383 use std::path::PathBuf;
384
385 const SAMPLE: &str = r#"
386import os
387from pathlib import Path
388
389class Parent:
390 """Base class."""
391 def greet(self):
392 return "hello"
393
394class Child(Parent):
395 """Child class."""
396 def greet(self):
397 return "hi"
398
399def helper():
400 """A helper."""
401 return 42
402
403def caller():
404 """Calls helper."""
405 x = helper()
406 return x
407"#;
408
409 #[test]
410 fn test_containment_edges() {
411 let path = PathBuf::from("test.py");
412 let result = parse_python_file(&path, SAMPLE).unwrap();
413 let resolver = NameResolver::from_nodes(&result.nodes);
414 let edges = extract_edges(&[result], &resolver);
415
416 let containment: Vec<_> = edges
417 .iter()
418 .filter(|e| e.predicate == CodeEdgePredicate::Contains)
419 .collect();
420
421 assert!(
423 containment.len() >= 5,
424 "Expected >= 5 containment edges, got {}",
425 containment.len()
426 );
427 }
428
429 #[test]
430 fn test_import_edges() {
431 let path = PathBuf::from("test.py");
432 let result = parse_python_file(&path, SAMPLE).unwrap();
433 let resolver = NameResolver::from_nodes(&result.nodes);
434 let edges = extract_edges(&[result], &resolver);
435
436 let imports: Vec<_> = edges
437 .iter()
438 .filter(|e| e.predicate == CodeEdgePredicate::Imports)
439 .collect();
440
441 assert!(
442 imports.len() >= 2,
443 "Expected >= 2 import edges, got {}",
444 imports.len()
445 );
446
447 let ext_imports: Vec<_> = imports
449 .iter()
450 .filter(|e| e.target_id.starts_with("ext:"))
451 .collect();
452 assert!(
453 !ext_imports.is_empty(),
454 "External imports should have ext: prefix"
455 );
456 }
457
458 #[test]
459 fn test_inheritance_edges() {
460 let path = PathBuf::from("test.py");
461 let result = parse_python_file(&path, SAMPLE).unwrap();
462 let resolver = NameResolver::from_nodes(&result.nodes);
463 let edges = extract_edges(&[result], &resolver);
464
465 let inheritance: Vec<_> = edges
466 .iter()
467 .filter(|e| e.predicate == CodeEdgePredicate::InheritsFrom)
468 .collect();
469
470 assert_eq!(
471 inheritance.len(),
472 1,
473 "Should have 1 inheritance edge (Child→Parent)"
474 );
475 assert!(inheritance[0].source_id.contains("Child"));
476 }
477
478 #[test]
479 fn test_extract_bases_from_signature() {
480 assert_eq!(
481 extract_bases_from_signature("class Child(Parent, Mixin)"),
482 vec!["Parent", "Mixin"]
483 );
484 assert_eq!(
485 extract_bases_from_signature("class Foo"),
486 Vec::<String>::new()
487 );
488 assert_eq!(
489 extract_bases_from_signature("class Bar()"),
490 Vec::<String>::new()
491 );
492 }
493
494 #[test]
495 fn test_name_resolver() {
496 let nodes = vec![CodeNode {
497 id: "func:brain/utils.py::helper".to_string(),
498 kind: CodeNodeKind::Function,
499 parent_id: None,
500 name: "helper".to_string(),
501 signature: None,
502 docstring: None,
503 body_hash: None,
504 body: None,
505 loc: None,
506 cyclomatic_complexity: None,
507 coverage_pct: None,
508 last_modified: None,
509 ..Default::default()
510 }];
511
512 let resolver = NameResolver::from_nodes(&nodes);
513
514 assert_eq!(
516 resolver.resolve("helper"),
517 Some(&"func:brain/utils.py::helper".to_string())
518 );
519
520 assert_eq!(
522 resolver.resolve("func:brain/utils.py::helper"),
523 Some(&"func:brain/utils.py::helper".to_string())
524 );
525 }
526}