Skip to main content

nusy_codegraph/
edges.rs

1//! Edge extraction — build CodeEdges from parsed CodeNodes.
2//!
3//! Extracts containment, import, inheritance, and call edges
4//! from the parsed code graph. Uses name resolution to map
5//! references to CodeNode IDs.
6
7use 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/// Name resolution table: maps fully-qualified names to CodeNode IDs.
14#[derive(Debug, Default)]
15pub struct NameResolver {
16    /// Exact name → node ID.
17    name_to_id: HashMap<String, String>,
18    /// Short name → node ID (for unqualified references).
19    short_name_to_id: HashMap<String, String>,
20}
21
22impl NameResolver {
23    /// Build a resolver from a set of CodeNodes.
24    pub fn from_nodes(nodes: &[CodeNode]) -> Self {
25        let mut resolver = Self::default();
26        for node in nodes {
27            // Register by full ID
28            resolver.name_to_id.insert(node.id.clone(), node.id.clone());
29
30            // Register by module-qualified name (e.g. "brain.perception.signal_fusion.fuse")
31            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            // Register by short name (last component)
37            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    /// Resolve a name to a CodeNode ID.
48    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
55/// Extract all edges from parsed results.
56///
57/// Returns edges for:
58/// - Containment (parent→child via parent_id)
59/// - Imports (file→module)
60/// - Inheritance (class→base class)
61/// - Calls (detected via text scanning, best-effort)
62pub fn extract_edges(parse_results: &[ParseResult], resolver: &NameResolver) -> Vec<CodeEdge> {
63    let mut edges = Vec::new();
64
65    // Collect all nodes for cross-referencing
66    let all_nodes: Vec<&CodeNode> = parse_results.iter().flat_map(|r| &r.nodes).collect();
67
68    // 1. Containment edges (from parent_id)
69    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    // 2. Import edges
82    let all_imports: Vec<&ImportInfo> = parse_results.iter().flat_map(|r| &r.imports).collect();
83
84    for imp in &all_imports {
85        // Try to resolve the imported module to a known node
86        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    // 3. Inheritance edges (from class signatures)
99    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
123/// Extract call edges, preferring SCIP data when available.
124///
125/// When `crate_dir` is `Some`, this first attempts `extract_scip_call_edges`
126/// (which uses rust-analyzer for compiler-quality call detection). If rust-analyzer
127/// is unavailable or fails, falls back to text-scanning via `text_scan_call_edges`.
128///
129/// Pass `crate_dir: None` to always use text scanning (e.g. for Python).
130pub 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    // Collect all nodes for both SCIP and text-scan paths.
137    let all_nodes: Vec<CodeNode> = parse_results.iter().flat_map(|r| r.nodes.clone()).collect();
138
139    // Try SCIP first (Rust only — requires rust-analyzer).
140    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    // Fall back to text scanning.
159    text_scan_call_edges(&all_nodes, source_texts)
160}
161
162/// Text-based call edge extraction (best-effort).
163///
164/// Scans function/method bodies for identifiers that match known callable names.
165/// Not precise — misses indirect calls, dynamic dispatch, and variable-mediated calls.
166/// Use `extract_scip_call_edges` instead when rust-analyzer is available.
167fn 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    // Build set of callable names
174    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    // Scan each function/method body for calls
186    for node in all_nodes {
187        if !matches!(
188            node.kind,
189            CodeNodeKind::Function | CodeNodeKind::Method | CodeNodeKind::Test
190        ) {
191            continue;
192        }
193
194        // Get the source file for this node
195        let file_path = node_id_to_path(&node.id);
196        let Some(source) = source_texts.get(&file_path) else {
197            continue;
198        };
199
200        // Simple heuristic: look for `name(` patterns in the source
201        // This is imprecise but catches most direct calls
202        for (name, target_id) in &callable_names {
203            if *target_id == node.id {
204                continue; // Skip self-references
205            }
206
207            let call_pattern = format!("{name}(");
208            // Count occurrences as a rough weight
209            let count = source.matches(&call_pattern).count();
210            if count > 0 {
211                // Verify we're not just matching substring
212                // (e.g., "test_fuse(" shouldn't match "fuse(")
213                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
235// ─── Helpers ────────────────────────────────────────────────────────────────
236
237/// Convert a CodeNode to its module-qualified name.
238fn node_to_qualified_name(node: &CodeNode) -> String {
239    // Extract path from ID: "func:brain/perception/signal_fusion.py::fuse" → "brain.perception.signal_fusion.fuse"
240    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        // method: "Class::method" → "module.Class.method"
250        format!("{mod_path}.{}", name.replace("::", "."))
251    } else {
252        format!("{mod_path}.{name}")
253    }
254}
255
256/// Extract file path from a CodeNode ID.
257fn 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
265/// Resolve an import to a CodeNode ID.
266fn resolve_import(imp: &ImportInfo, resolver: &NameResolver) -> Option<String> {
267    // Try exact module match (e.g. "brain.utils.helper" → "mod:brain/utils/helper.py")
268    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    // Try file ID
274    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    // Try module name directly
280    resolver.resolve(&imp.module).cloned()
281}
282
283/// Extract base class names from a class signature like "class Child(Parent, Mixin)".
284fn 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
302/// Extract cross-file edges using the RustModuleResolver.
303///
304/// Creates edges for:
305/// - Resolved `use` imports → specific CodeNode (replaces generic module edges)
306/// - `impl Trait for Type` → ImplementsTrait edge
307/// - `pub use` re-exports → ReExports edge
308/// - `#[test]` functions → TestTargets edge (naming convention: test_X targets X)
309pub 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    // 1. Resolved import edges (use statements → specific targets)
318    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            // Check if this is a re-export (pub use)
330            // We detect re-exports by checking if the importing node is a RustUse with "pub" in signature
331            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    // 2. ImplementsTrait edges (from RustImpl nodes)
354    for node in &all_nodes {
355        if node.kind != CodeNodeKind::RustImpl {
356            continue;
357        }
358        // Signature pattern: "impl TraitName for TypeName" or "impl TypeName"
359        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    // 3. TestTargets edges (naming convention: test_foo → foo)
376    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
396/// Extract the trait name from an `impl Trait for Type` signature.
397///
398/// Returns `Some("TraitName")` for `impl TraitName for TypeName`,
399/// Returns `None` for `impl TypeName` (inherent impl).
400fn 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        // Handle generic bounds: "Display" from "impl Display for Foo<T>"
405        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 // inherent impl, no trait
414    }
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        // Should have containment edges for module, classes, methods, functions
460        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        // External imports should have ext: prefix
486        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        // Resolve by short name
553        assert_eq!(
554            resolver.resolve("helper"),
555            Some(&"func:brain/utils.py::helper".to_string())
556        );
557
558        // Resolve by full ID
559        assert_eq!(
560            resolver.resolve("func:brain/utils.py::helper"),
561            Some(&"func:brain/utils.py::helper".to_string())
562        );
563    }
564}