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 std::collections::HashMap;
10
11/// Name resolution table: maps fully-qualified names to CodeNode IDs.
12#[derive(Debug, Default)]
13pub struct NameResolver {
14    /// Exact name → node ID.
15    name_to_id: HashMap<String, String>,
16    /// Short name → node ID (for unqualified references).
17    short_name_to_id: HashMap<String, String>,
18}
19
20impl NameResolver {
21    /// Build a resolver from a set of CodeNodes.
22    pub fn from_nodes(nodes: &[CodeNode]) -> Self {
23        let mut resolver = Self::default();
24        for node in nodes {
25            // Register by full ID
26            resolver.name_to_id.insert(node.id.clone(), node.id.clone());
27
28            // Register by module-qualified name (e.g. "brain.perception.signal_fusion.fuse")
29            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            // Register by short name (last component)
35            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    /// Resolve a name to a CodeNode ID.
46    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
53/// Extract all edges from parsed results.
54///
55/// Returns edges for:
56/// - Containment (parent→child via parent_id)
57/// - Imports (file→module)
58/// - Inheritance (class→base class)
59/// - Calls (detected via text scanning, best-effort)
60pub fn extract_edges(parse_results: &[ParseResult], resolver: &NameResolver) -> Vec<CodeEdge> {
61    let mut edges = Vec::new();
62
63    // Collect all nodes for cross-referencing
64    let all_nodes: Vec<&CodeNode> = parse_results.iter().flat_map(|r| &r.nodes).collect();
65
66    // 1. Containment edges (from parent_id)
67    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    // 2. Import edges
80    let all_imports: Vec<&ImportInfo> = parse_results.iter().flat_map(|r| &r.imports).collect();
81
82    for imp in &all_imports {
83        // Try to resolve the imported module to a known node
84        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    // 3. Inheritance edges (from class signatures)
97    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
121/// Extract call edges using simple text-based scanning.
122///
123/// This is a best-effort approach: scans function/method bodies for
124/// identifiers that match known function/method names. Does not do
125/// full flow analysis.
126pub 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    // Build set of callable names
136    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    // Scan each function/method body for calls
148    for node in &all_nodes {
149        if !matches!(
150            node.kind,
151            CodeNodeKind::Function | CodeNodeKind::Method | CodeNodeKind::Test
152        ) {
153            continue;
154        }
155
156        // Get the source file for this node
157        let file_path = node_id_to_path(&node.id);
158        let Some(source) = source_texts.get(&file_path) else {
159            continue;
160        };
161
162        // Simple heuristic: look for `name(` patterns in the source
163        // This is imprecise but catches most direct calls
164        for (name, target_id) in &callable_names {
165            if *target_id == node.id {
166                continue; // Skip self-references
167            }
168
169            let call_pattern = format!("{name}(");
170            // Count occurrences as a rough weight
171            let count = source.matches(&call_pattern).count();
172            if count > 0 {
173                // Verify we're not just matching substring
174                // (e.g., "test_fuse(" shouldn't match "fuse(")
175                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
197// ─── Helpers ────────────────────────────────────────────────────────────────
198
199/// Convert a CodeNode to its module-qualified name.
200fn node_to_qualified_name(node: &CodeNode) -> String {
201    // Extract path from ID: "func:brain/perception/signal_fusion.py::fuse" → "brain.perception.signal_fusion.fuse"
202    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        // method: "Class::method" → "module.Class.method"
212        format!("{mod_path}.{}", name.replace("::", "."))
213    } else {
214        format!("{mod_path}.{name}")
215    }
216}
217
218/// Extract file path from a CodeNode ID.
219fn 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
227/// Resolve an import to a CodeNode ID.
228fn resolve_import(imp: &ImportInfo, resolver: &NameResolver) -> Option<String> {
229    // Try exact module match (e.g. "brain.utils.helper" → "mod:brain/utils/helper.py")
230    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    // Try file ID
236    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    // Try module name directly
242    resolver.resolve(&imp.module).cloned()
243}
244
245/// Extract base class names from a class signature like "class Child(Parent, Mixin)".
246fn 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
264/// Extract cross-file edges using the RustModuleResolver.
265///
266/// Creates edges for:
267/// - Resolved `use` imports → specific CodeNode (replaces generic module edges)
268/// - `impl Trait for Type` → ImplementsTrait edge
269/// - `pub use` re-exports → ReExports edge
270/// - `#[test]` functions → TestTargets edge (naming convention: test_X targets X)
271pub 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    // 1. Resolved import edges (use statements → specific targets)
280    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            // Check if this is a re-export (pub use)
292            // We detect re-exports by checking if the importing node is a RustUse with "pub" in signature
293            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    // 2. ImplementsTrait edges (from RustImpl nodes)
316    for node in &all_nodes {
317        if node.kind != CodeNodeKind::RustImpl {
318            continue;
319        }
320        // Signature pattern: "impl TraitName for TypeName" or "impl TypeName"
321        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    // 3. TestTargets edges (naming convention: test_foo → foo)
338    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
358/// Extract the trait name from an `impl Trait for Type` signature.
359///
360/// Returns `Some("TraitName")` for `impl TraitName for TypeName`,
361/// Returns `None` for `impl TypeName` (inherent impl).
362fn 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        // Handle generic bounds: "Display" from "impl Display for Foo<T>"
367        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 // inherent impl, no trait
376    }
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        // Should have containment edges for module, classes, methods, functions
422        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        // External imports should have ext: prefix
448        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        // Resolve by short name
515        assert_eq!(
516            resolver.resolve("helper"),
517            Some(&"func:brain/utils.py::helper".to_string())
518        );
519
520        // Resolve by full ID
521        assert_eq!(
522            resolver.resolve("func:brain/utils.py::helper"),
523            Some(&"func:brain/utils.py::helper".to_string())
524        );
525    }
526}