1use crate::parser::{ImportInfo, ParseResult};
8use crate::schema::{CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind};
9use crate::scip_calls;
10use std::collections::HashMap;
11use std::path::Path;
12
13#[derive(Debug, Default)]
15pub struct NameResolver {
16 name_to_id: HashMap<String, String>,
18 short_name_to_id: HashMap<String, String>,
20}
21
22impl NameResolver {
23 pub fn from_nodes(nodes: &[CodeNode]) -> Self {
25 let mut resolver = Self::default();
26 for node in nodes {
27 resolver.name_to_id.insert(node.id.clone(), node.id.clone());
29
30 let qualified = node_to_qualified_name(node);
32 if !qualified.is_empty() {
33 resolver.name_to_id.insert(qualified, node.id.clone());
34 }
35
36 if !node.name.is_empty() {
38 resolver
39 .short_name_to_id
40 .entry(node.name.clone())
41 .or_insert_with(|| node.id.clone());
42 }
43 }
44 resolver
45 }
46
47 pub fn resolve(&self, name: &str) -> Option<&String> {
49 self.name_to_id
50 .get(name)
51 .or_else(|| self.short_name_to_id.get(name))
52 }
53}
54
55pub fn extract_edges(parse_results: &[ParseResult], resolver: &NameResolver) -> Vec<CodeEdge> {
63 let mut edges = Vec::new();
64
65 let all_nodes: Vec<&CodeNode> = parse_results.iter().flat_map(|r| &r.nodes).collect();
67
68 for node in &all_nodes {
70 if let Some(parent_id) = &node.parent_id {
71 edges.push(CodeEdge {
72 source_id: parent_id.clone(),
73 target_id: node.id.clone(),
74 predicate: CodeEdgePredicate::Contains,
75 weight: None,
76 commit_id: None,
77 });
78 }
79 }
80
81 let all_imports: Vec<&ImportInfo> = parse_results.iter().flat_map(|r| &r.imports).collect();
83
84 for imp in &all_imports {
85 let target = resolve_import(imp, resolver);
87 let target_id = target.unwrap_or_else(|| format!("ext:{}", imp.module));
88
89 edges.push(CodeEdge {
90 source_id: imp.file_node_id.clone(),
91 target_id,
92 predicate: CodeEdgePredicate::Imports,
93 weight: None,
94 commit_id: None,
95 });
96 }
97
98 for node in &all_nodes {
100 if node.kind == CodeNodeKind::Class
101 && let Some(sig) = &node.signature
102 {
103 let bases = extract_bases_from_signature(sig);
104 for base in bases {
105 let target_id = resolver
106 .resolve(&base)
107 .cloned()
108 .unwrap_or_else(|| format!("ext:{base}"));
109 edges.push(CodeEdge {
110 source_id: node.id.clone(),
111 target_id,
112 predicate: CodeEdgePredicate::InheritsFrom,
113 weight: None,
114 commit_id: None,
115 });
116 }
117 }
118 }
119
120 edges
121}
122
123pub fn extract_call_edges(
131 parse_results: &[ParseResult],
132 _resolver: &NameResolver,
133 source_texts: &HashMap<String, String>,
134 crate_dir: Option<&Path>,
135) -> Vec<CodeEdge> {
136 let all_nodes: Vec<CodeNode> = parse_results.iter().flat_map(|r| r.nodes.clone()).collect();
138
139 if let Some(dir) = crate_dir {
141 let result = scip_calls::extract_scip_call_edges(dir, &all_nodes);
142 if !result.edges.is_empty() {
143 tracing::info!(
144 "SCIP call edges: {} resolved, {} unresolved ({} docs)",
145 result.symbols_resolved,
146 result.unresolved_references,
147 result.documents_processed
148 );
149 return result.edges;
150 } else if !result.warnings.is_empty() {
151 tracing::debug!(
152 "SCIP unavailable or failed, falling back to text scan: {}",
153 result.warnings.join("; ")
154 );
155 }
156 }
157
158 text_scan_call_edges(&all_nodes, source_texts)
160}
161
162fn text_scan_call_edges(
168 all_nodes: &[CodeNode],
169 source_texts: &HashMap<String, String>,
170) -> Vec<CodeEdge> {
171 let mut edges = Vec::new();
172
173 let callable_names: HashMap<&str, &str> = all_nodes
175 .iter()
176 .filter(|n| {
177 matches!(
178 n.kind,
179 CodeNodeKind::Function | CodeNodeKind::Method | CodeNodeKind::Test
180 )
181 })
182 .map(|n| (n.name.as_str(), n.id.as_str()))
183 .collect();
184
185 for node in all_nodes {
187 if !matches!(
188 node.kind,
189 CodeNodeKind::Function | CodeNodeKind::Method | CodeNodeKind::Test
190 ) {
191 continue;
192 }
193
194 let file_path = node_id_to_path(&node.id);
196 let Some(source) = source_texts.get(&file_path) else {
197 continue;
198 };
199
200 for (name, target_id) in &callable_names {
203 if *target_id == node.id {
204 continue; }
206
207 let call_pattern = format!("{name}(");
208 let count = source.matches(&call_pattern).count();
210 if count > 0 {
211 let valid = source.contains(&format!(".{call_pattern}"))
214 || source.contains(&format!(" {call_pattern}"))
215 || source.contains(&format!("={call_pattern}"))
216 || source.contains(&format!("({call_pattern}"))
217 || source.starts_with(&call_pattern);
218
219 if valid {
220 edges.push(CodeEdge {
221 source_id: node.id.clone(),
222 target_id: target_id.to_string(),
223 predicate: CodeEdgePredicate::Calls,
224 weight: Some(count as f32),
225 commit_id: None,
226 });
227 }
228 }
229 }
230 }
231
232 edges
233}
234
235fn node_to_qualified_name(node: &CodeNode) -> String {
239 let id = &node.id;
241 let after_colon = id.split_once(':').map(|(_, rest)| rest).unwrap_or(id);
242 let (path, name) = after_colon.split_once("::").unwrap_or((after_colon, ""));
243
244 let mod_path = path.strip_suffix(".py").unwrap_or(path).replace('/', ".");
245
246 if name.is_empty() {
247 mod_path
248 } else if name.contains("::") {
249 format!("{mod_path}.{}", name.replace("::", "."))
251 } else {
252 format!("{mod_path}.{name}")
253 }
254}
255
256fn node_id_to_path(id: &str) -> String {
258 let after_colon = id.split_once(':').map(|(_, rest)| rest).unwrap_or(id);
259 after_colon
260 .split_once("::")
261 .map(|(path, _)| path.to_string())
262 .unwrap_or_else(|| after_colon.to_string())
263}
264
265fn resolve_import(imp: &ImportInfo, resolver: &NameResolver) -> Option<String> {
267 let mod_id = format!("mod:{}.py", imp.module.replace('.', "/"));
269 if let Some(id) = resolver.resolve(&mod_id) {
270 return Some(id.clone());
271 }
272
273 let file_id = format!("file:{}.py", imp.module.replace('.', "/"));
275 if let Some(id) = resolver.resolve(&file_id) {
276 return Some(id.clone());
277 }
278
279 resolver.resolve(&imp.module).cloned()
281}
282
283fn extract_bases_from_signature(sig: &str) -> Vec<String> {
285 let Some(start) = sig.find('(') else {
286 return Vec::new();
287 };
288 let Some(end) = sig.rfind(')') else {
289 return Vec::new();
290 };
291 if start >= end {
292 return Vec::new();
293 }
294
295 sig[start + 1..end]
296 .split(',')
297 .map(|s| s.trim().to_string())
298 .filter(|s| !s.is_empty())
299 .collect()
300}
301
302pub fn extract_cross_file_edges(
310 parse_results: &[ParseResult],
311 resolver: &crate::module_resolver::RustModuleResolver,
312) -> Vec<CodeEdge> {
313 let mut edges = Vec::new();
314
315 let all_nodes: Vec<&CodeNode> = parse_results.iter().flat_map(|r| &r.nodes).collect();
316
317 let all_imports: Vec<&ImportInfo> = parse_results.iter().flat_map(|r| &r.imports).collect();
319 for imp in &all_imports {
320 let resolved = resolver.resolve_use(&imp.module, &imp.names);
321 for (name, target_id) in &resolved {
322 edges.push(CodeEdge {
323 source_id: imp.file_node_id.clone(),
324 target_id: target_id.clone(),
325 predicate: CodeEdgePredicate::Imports,
326 weight: None,
327 commit_id: None,
328 });
329 let use_node = all_nodes.iter().find(|n| {
332 n.kind == CodeNodeKind::RustUse
333 && n.name == *name
334 && n.parent_id.as_deref() == Some(&imp.file_node_id)
335 });
336 if let Some(use_node) = use_node
337 && use_node
338 .signature
339 .as_ref()
340 .is_some_and(|s| s.starts_with("pub"))
341 {
342 edges.push(CodeEdge {
343 source_id: imp.file_node_id.clone(),
344 target_id: target_id.clone(),
345 predicate: CodeEdgePredicate::ReExports,
346 weight: None,
347 commit_id: None,
348 });
349 }
350 }
351 }
352
353 for node in &all_nodes {
355 if node.kind != CodeNodeKind::RustImpl {
356 continue;
357 }
358 if let Some(sig) = &node.signature
360 && let Some(trait_name) = extract_trait_from_impl(sig)
361 {
362 let target_id = resolver
363 .resolve_name(&trait_name)
364 .unwrap_or_else(|| format!("ext:{trait_name}"));
365 edges.push(CodeEdge {
366 source_id: node.id.clone(),
367 target_id,
368 predicate: CodeEdgePredicate::ImplementsTrait,
369 weight: None,
370 commit_id: None,
371 });
372 }
373 }
374
375 for node in &all_nodes {
377 if node.kind != CodeNodeKind::RustTest && node.kind != CodeNodeKind::Test {
378 continue;
379 }
380 if let Some(target_name) = node.name.strip_prefix("test_")
381 && let Some(target_id) = resolver.resolve_name(target_name)
382 {
383 edges.push(CodeEdge {
384 source_id: node.id.clone(),
385 target_id,
386 predicate: CodeEdgePredicate::TestTargets,
387 weight: None,
388 commit_id: None,
389 });
390 }
391 }
392
393 edges
394}
395
396fn extract_trait_from_impl(sig: &str) -> Option<String> {
401 let sig = sig.strip_prefix("impl").map(|s| s.trim())?;
402 if let Some(for_idx) = sig.find(" for ") {
403 let trait_part = sig[..for_idx].trim();
404 let name = trait_part
406 .split('<')
407 .next()
408 .unwrap_or(trait_part)
409 .trim()
410 .to_string();
411 if name.is_empty() { None } else { Some(name) }
412 } else {
413 None }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use crate::parser::parse_python_file;
421 use std::path::PathBuf;
422
423 const SAMPLE: &str = r#"
424import os
425from pathlib import Path
426
427class Parent:
428 """Base class."""
429 def greet(self):
430 return "hello"
431
432class Child(Parent):
433 """Child class."""
434 def greet(self):
435 return "hi"
436
437def helper():
438 """A helper."""
439 return 42
440
441def caller():
442 """Calls helper."""
443 x = helper()
444 return x
445"#;
446
447 #[test]
448 fn test_containment_edges() {
449 let path = PathBuf::from("test.py");
450 let result = parse_python_file(&path, SAMPLE).unwrap();
451 let resolver = NameResolver::from_nodes(&result.nodes);
452 let edges = extract_edges(&[result], &resolver);
453
454 let containment: Vec<_> = edges
455 .iter()
456 .filter(|e| e.predicate == CodeEdgePredicate::Contains)
457 .collect();
458
459 assert!(
461 containment.len() >= 5,
462 "Expected >= 5 containment edges, got {}",
463 containment.len()
464 );
465 }
466
467 #[test]
468 fn test_import_edges() {
469 let path = PathBuf::from("test.py");
470 let result = parse_python_file(&path, SAMPLE).unwrap();
471 let resolver = NameResolver::from_nodes(&result.nodes);
472 let edges = extract_edges(&[result], &resolver);
473
474 let imports: Vec<_> = edges
475 .iter()
476 .filter(|e| e.predicate == CodeEdgePredicate::Imports)
477 .collect();
478
479 assert!(
480 imports.len() >= 2,
481 "Expected >= 2 import edges, got {}",
482 imports.len()
483 );
484
485 let ext_imports: Vec<_> = imports
487 .iter()
488 .filter(|e| e.target_id.starts_with("ext:"))
489 .collect();
490 assert!(
491 !ext_imports.is_empty(),
492 "External imports should have ext: prefix"
493 );
494 }
495
496 #[test]
497 fn test_inheritance_edges() {
498 let path = PathBuf::from("test.py");
499 let result = parse_python_file(&path, SAMPLE).unwrap();
500 let resolver = NameResolver::from_nodes(&result.nodes);
501 let edges = extract_edges(&[result], &resolver);
502
503 let inheritance: Vec<_> = edges
504 .iter()
505 .filter(|e| e.predicate == CodeEdgePredicate::InheritsFrom)
506 .collect();
507
508 assert_eq!(
509 inheritance.len(),
510 1,
511 "Should have 1 inheritance edge (Child→Parent)"
512 );
513 assert!(inheritance[0].source_id.contains("Child"));
514 }
515
516 #[test]
517 fn test_extract_bases_from_signature() {
518 assert_eq!(
519 extract_bases_from_signature("class Child(Parent, Mixin)"),
520 vec!["Parent", "Mixin"]
521 );
522 assert_eq!(
523 extract_bases_from_signature("class Foo"),
524 Vec::<String>::new()
525 );
526 assert_eq!(
527 extract_bases_from_signature("class Bar()"),
528 Vec::<String>::new()
529 );
530 }
531
532 #[test]
533 fn test_name_resolver() {
534 let nodes = vec![CodeNode {
535 id: "func:brain/utils.py::helper".to_string(),
536 kind: CodeNodeKind::Function,
537 parent_id: None,
538 name: "helper".to_string(),
539 signature: None,
540 docstring: None,
541 body_hash: None,
542 body: None,
543 loc: None,
544 cyclomatic_complexity: None,
545 coverage_pct: None,
546 last_modified: None,
547 ..Default::default()
548 }];
549
550 let resolver = NameResolver::from_nodes(&nodes);
551
552 assert_eq!(
554 resolver.resolve("helper"),
555 Some(&"func:brain/utils.py::helper".to_string())
556 );
557
558 assert_eq!(
560 resolver.resolve("func:brain/utils.py::helper"),
561 Some(&"func:brain/utils.py::helper".to_string())
562 );
563 }
564}