Skip to main content

nusy_codegraph/
parser.rs

1//! tree-sitter Python parser — extract CodeNodes from Python source files.
2//!
3//! Parses Python source into a tree of CodeNodes representing files, classes,
4//! functions, methods, and imports. Builds containment hierarchy via parent_id.
5
6use crate::schema::{CodeNode, CodeNodeKind};
7use sha2::{Digest, Sha256};
8use std::path::Path;
9
10/// Errors from parsing operations.
11#[derive(Debug, thiserror::Error)]
12pub enum ParseError {
13    #[error("IO error: {0}")]
14    Io(#[from] std::io::Error),
15
16    #[error("tree-sitter parse failed for {path}")]
17    ParseFailed { path: String },
18
19    #[error("tree-sitter language error: {0}")]
20    Language(#[from] tree_sitter::LanguageError),
21}
22
23/// Result of parsing a Python file.
24#[derive(Debug)]
25pub struct ParseResult {
26    /// All code nodes extracted from the file.
27    pub nodes: Vec<CodeNode>,
28    /// Import statements found (module path strings).
29    pub imports: Vec<ImportInfo>,
30}
31
32/// An import statement extracted from source.
33#[derive(Debug, Clone)]
34pub struct ImportInfo {
35    /// The file node ID that contains this import.
36    pub file_node_id: String,
37    /// The module being imported (e.g. "brain.perception.signal_fusion").
38    pub module: String,
39    /// Specific names imported (empty for `import X` style).
40    pub names: Vec<String>,
41    /// Whether this is a relative import.
42    pub is_relative: bool,
43}
44
45/// Parse a Python source file into CodeNodes.
46///
47/// Returns nodes for the file, all classes, functions, methods,
48/// and import information.
49pub fn parse_python_file(path: &Path, source: &str) -> Result<ParseResult, ParseError> {
50    let mut parser = tree_sitter::Parser::new();
51    let language = tree_sitter_python::LANGUAGE;
52    parser.set_language(&language.into())?;
53
54    let tree = parser
55        .parse(source, None)
56        .ok_or_else(|| ParseError::ParseFailed {
57            path: path.display().to_string(),
58        })?;
59
60    let path_str = path.display().to_string();
61    let file_id = format!("file:{path_str}");
62
63    // Module ID (file without extension)
64    let mod_path = path_str
65        .strip_suffix(".py")
66        .unwrap_or(&path_str)
67        .replace('/', ".");
68    let mod_id = format!("mod:{path_str}");
69
70    // File node
71    let file_node = CodeNode {
72        id: file_id.clone(),
73        kind: CodeNodeKind::File,
74        parent_id: None,
75        name: path
76            .file_name()
77            .map(|n| n.to_string_lossy().to_string())
78            .unwrap_or_default(),
79        signature: None,
80        docstring: extract_module_docstring(source, &tree),
81        body_hash: Some(sha256_hex(source.as_bytes())),
82        body: None, // File-level body omitted (too large)
83        loc: Some(source.lines().count() as i32),
84        cyclomatic_complexity: None,
85        coverage_pct: None,
86        last_modified: None,
87        ..Default::default()
88    };
89
90    // Module node (parent for top-level definitions)
91    let module_node = CodeNode {
92        id: mod_id.clone(),
93        kind: CodeNodeKind::Module,
94        parent_id: Some(file_id.clone()),
95        name: mod_path,
96        signature: None,
97        docstring: file_node.docstring.clone(),
98        body_hash: file_node.body_hash.clone(),
99        body: None, // Module-level body omitted (too large)
100        loc: file_node.loc,
101        cyclomatic_complexity: None,
102        coverage_pct: None,
103        last_modified: None,
104        ..Default::default()
105    };
106
107    let mut nodes = vec![file_node, module_node];
108    let mut imports = Vec::new();
109
110    let root = tree.root_node();
111    let src = source.as_bytes();
112
113    // Walk top-level children
114    let mut cursor = root.walk();
115    for child in root.children(&mut cursor) {
116        match child.kind() {
117            "function_definition" | "decorated_definition" => {
118                let func_node = child_with_kind(&child, "function_definition").unwrap_or(child);
119                let extracted = extract_function(&func_node, src, &path_str, &mod_id, None);
120                nodes.extend(extracted);
121            }
122            "class_definition" => {
123                let class_nodes = extract_class(&child, src, &path_str, &mod_id);
124                nodes.extend(class_nodes);
125            }
126            "import_statement" => {
127                if let Some(imp) = extract_import(&child, src, &file_id, false) {
128                    imports.push(imp);
129                }
130            }
131            "import_from_statement" => {
132                if let Some(imp) = extract_import_from(&child, src, &file_id) {
133                    imports.push(imp);
134                }
135            }
136            _ => {}
137        }
138    }
139
140    Ok(ParseResult { nodes, imports })
141}
142
143// ─── Extraction helpers ─────────────────────────────────────────────────────
144
145fn extract_function(
146    node: &tree_sitter::Node,
147    src: &[u8],
148    path: &str,
149    parent_id: &str,
150    class_name: Option<&str>,
151) -> Vec<CodeNode> {
152    let name = node_child_text(node, "name", src).unwrap_or_default();
153    let is_test = name.starts_with("test_") || name.starts_with("test");
154
155    let kind = if class_name.is_some() {
156        CodeNodeKind::Method
157    } else if is_test {
158        CodeNodeKind::Test
159    } else {
160        CodeNodeKind::Function
161    };
162
163    let id = match class_name {
164        Some(cls) => format!("method:{path}::{cls}::{name}"),
165        None => format!("func:{path}::{name}"),
166    };
167
168    let signature = extract_signature(node, src);
169    let docstring = extract_docstring(node, src);
170    let body = node_text(node, src);
171    let body_hash = sha256_hex(body.as_bytes());
172    let loc = (node.end_position().row - node.start_position().row + 1) as i32;
173    let complexity = compute_cyclomatic_complexity(node, src);
174
175    vec![CodeNode {
176        id,
177        kind,
178        parent_id: Some(parent_id.to_string()),
179        name,
180        signature: Some(signature),
181        docstring,
182        body_hash: Some(body_hash),
183        body: Some(body),
184        loc: Some(loc),
185        cyclomatic_complexity: Some(complexity),
186        coverage_pct: None,
187        last_modified: None,
188        ..Default::default()
189    }]
190}
191
192fn extract_class(
193    node: &tree_sitter::Node,
194    src: &[u8],
195    path: &str,
196    parent_id: &str,
197) -> Vec<CodeNode> {
198    let name = node_child_text(node, "name", src).unwrap_or_default();
199    let class_id = format!("class:{path}::{name}");
200
201    let bases = extract_class_bases(node, src);
202    let signature = if bases.is_empty() {
203        format!("class {name}")
204    } else {
205        format!("class {name}({})", bases.join(", "))
206    };
207
208    let docstring = extract_docstring(node, src);
209    let body_text = node_text(node, src);
210    let body_hash = sha256_hex(body_text.as_bytes());
211    let loc = (node.end_position().row - node.start_position().row + 1) as i32;
212
213    let mut nodes = vec![CodeNode {
214        id: class_id.clone(),
215        kind: CodeNodeKind::Class,
216        parent_id: Some(parent_id.to_string()),
217        name: name.clone(),
218        signature: Some(signature),
219        docstring,
220        body_hash: Some(body_hash),
221        body: Some(body_text),
222        loc: Some(loc),
223        cyclomatic_complexity: None,
224        coverage_pct: None,
225        last_modified: None,
226        ..Default::default()
227    }];
228
229    // Extract methods from the class body
230    if let Some(body) = node.child_by_field_name("body") {
231        let mut cursor = body.walk();
232        for child in body.children(&mut cursor) {
233            match child.kind() {
234                "function_definition" | "decorated_definition" => {
235                    let func_node = child_with_kind(&child, "function_definition").unwrap_or(child);
236                    let method_nodes =
237                        extract_function(&func_node, src, path, &class_id, Some(&name));
238                    nodes.extend(method_nodes);
239                }
240                _ => {}
241            }
242        }
243    }
244
245    nodes
246}
247
248fn extract_import(
249    node: &tree_sitter::Node,
250    src: &[u8],
251    file_id: &str,
252    is_relative: bool,
253) -> Option<ImportInfo> {
254    // `import X` or `import X.Y.Z`
255    let mut cursor = node.walk();
256    for child in node.children(&mut cursor) {
257        if child.kind() == "dotted_name" {
258            let module = node_text(&child, src);
259            return Some(ImportInfo {
260                file_node_id: file_id.to_string(),
261                module,
262                names: Vec::new(),
263                is_relative,
264            });
265        }
266    }
267    None
268}
269
270fn extract_import_from(node: &tree_sitter::Node, src: &[u8], file_id: &str) -> Option<ImportInfo> {
271    // Parse `from X import Y, Z` using the full text approach
272    // tree-sitter's child structure varies, so text parsing is more reliable
273    let full_text = node_text(node, src);
274    let is_relative = full_text
275        .trim_start_matches("from")
276        .trim_start()
277        .starts_with('.');
278
279    // Split on "import" keyword
280    let parts: Vec<&str> = full_text.splitn(2, "import").collect();
281    if parts.len() < 2 {
282        return None;
283    }
284
285    let module = parts[0]
286        .trim()
287        .strip_prefix("from")
288        .unwrap_or(parts[0])
289        .trim()
290        .to_string();
291
292    let names: Vec<String> = parts[1]
293        .split(',')
294        .map(|s| {
295            s.trim()
296                .split(" as ")
297                .next()
298                .unwrap_or("")
299                .trim()
300                .to_string()
301        })
302        .filter(|s| !s.is_empty())
303        .collect();
304
305    if module.is_empty() && names.is_empty() {
306        return None;
307    }
308
309    Some(ImportInfo {
310        file_node_id: file_id.to_string(),
311        module,
312        names,
313        is_relative,
314    })
315}
316
317// ─── AST utility helpers ────────────────────────────────────────────────────
318
319fn node_text(node: &tree_sitter::Node, src: &[u8]) -> String {
320    node.utf8_text(src).unwrap_or("").to_string()
321}
322
323fn node_child_text(node: &tree_sitter::Node, field: &str, src: &[u8]) -> Option<String> {
324    node.child_by_field_name(field).map(|n| node_text(&n, src))
325}
326
327fn child_with_kind<'a>(node: &tree_sitter::Node<'a>, kind: &str) -> Option<tree_sitter::Node<'a>> {
328    let mut cursor = node.walk();
329    node.children(&mut cursor).find(|c| c.kind() == kind)
330}
331
332fn extract_signature(node: &tree_sitter::Node, src: &[u8]) -> String {
333    let name = node_child_text(node, "name", src).unwrap_or_default();
334    let params = node
335        .child_by_field_name("parameters")
336        .map(|n| node_text(&n, src))
337        .unwrap_or_else(|| "()".to_string());
338    let ret = node
339        .child_by_field_name("return_type")
340        .map(|n| format!(" -> {}", node_text(&n, src)))
341        .unwrap_or_default();
342
343    // Check for async
344    let is_async = node
345        .parent()
346        .is_some_and(|p| p.kind() == "decorated_definition")
347        || node_text(node, src).starts_with("async ");
348
349    let prefix = if is_async { "async def" } else { "def" };
350    format!("{prefix} {name}{params}{ret}")
351}
352
353fn extract_docstring(node: &tree_sitter::Node, src: &[u8]) -> Option<String> {
354    let body = node.child_by_field_name("body")?;
355    let mut cursor = body.walk();
356    let first_stmt = body.children(&mut cursor).next()?;
357
358    if first_stmt.kind() == "expression_statement" {
359        let mut inner_cursor = first_stmt.walk();
360        let expr = first_stmt.children(&mut inner_cursor).next()?;
361        if expr.kind() == "string" || expr.kind() == "concatenated_string" {
362            let text = node_text(&expr, src);
363            return Some(strip_docstring_quotes(&text));
364        }
365    }
366    None
367}
368
369fn extract_module_docstring(source: &str, tree: &tree_sitter::Tree) -> Option<String> {
370    let root = tree.root_node();
371    let src = source.as_bytes();
372    let mut cursor = root.walk();
373
374    for child in root.children(&mut cursor) {
375        if child.kind() == "expression_statement" {
376            let mut inner = child.walk();
377            if let Some(expr) = child.children(&mut inner).next()
378                && (expr.kind() == "string" || expr.kind() == "concatenated_string")
379            {
380                return Some(strip_docstring_quotes(&node_text(&expr, src)));
381            }
382        }
383        // Skip comments but stop at non-docstring statements
384        if child.kind() != "comment" && child.kind() != "expression_statement" {
385            break;
386        }
387    }
388    None
389}
390
391fn strip_docstring_quotes(s: &str) -> String {
392    let trimmed = s.trim();
393    if let Some(inner) = trimmed
394        .strip_prefix("\"\"\"")
395        .and_then(|s| s.strip_suffix("\"\"\""))
396    {
397        inner.trim().to_string()
398    } else if let Some(inner) = trimmed
399        .strip_prefix("'''")
400        .and_then(|s| s.strip_suffix("'''"))
401    {
402        inner.trim().to_string()
403    } else if let Some(inner) = trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
404        inner.trim().to_string()
405    } else if let Some(inner) = trimmed
406        .strip_prefix('\'')
407        .and_then(|s| s.strip_suffix('\''))
408    {
409        inner.trim().to_string()
410    } else {
411        trimmed.to_string()
412    }
413}
414
415fn extract_class_bases(node: &tree_sitter::Node, src: &[u8]) -> Vec<String> {
416    let Some(superclasses) = node.child_by_field_name("superclasses") else {
417        return Vec::new();
418    };
419    let mut bases = Vec::new();
420    let mut cursor = superclasses.walk();
421    for child in superclasses.children(&mut cursor) {
422        if child.kind() == "identifier" || child.kind() == "attribute" {
423            bases.push(node_text(&child, src));
424        }
425    }
426    bases
427}
428
429/// Simple cyclomatic complexity: count branching keywords.
430fn compute_cyclomatic_complexity(node: &tree_sitter::Node, src: &[u8]) -> i32 {
431    let text = node_text(node, src);
432    let mut complexity = 1; // base
433
434    for line in text.lines() {
435        let trimmed = line.trim();
436        // Count branching constructs
437        if trimmed.starts_with("if ")
438            || trimmed.starts_with("elif ")
439            || trimmed.starts_with("for ")
440            || trimmed.starts_with("while ")
441            || trimmed.starts_with("except ")
442            || trimmed.starts_with("except:")
443        {
444            complexity += 1;
445        }
446        // Count boolean operators
447        complexity += trimmed.matches(" and ").count() as i32;
448        complexity += trimmed.matches(" or ").count() as i32;
449    }
450
451    complexity
452}
453
454/// Compute SHA-256 hex digest.
455pub fn sha256_hex(data: &[u8]) -> String {
456    let mut hasher = Sha256::new();
457    hasher.update(data);
458    format!("{:x}", hasher.finalize())
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::path::PathBuf;
465
466    const SAMPLE_PYTHON: &str = r#"
467"""Module docstring."""
468
469import os
470from pathlib import Path
471from brain.utils import helper
472
473class SignalFusion:
474    """Fuses signals from multiple sources."""
475
476    def __init__(self, config):
477        """Initialize with config."""
478        self.config = config
479
480    def fuse(self, signals: list) -> dict:
481        """Fuse all signals.
482
483        Returns merged result.
484        """
485        result = {}
486        for s in signals:
487            if s.is_valid() and s.weight > 0:
488                result[s.name] = s.value
489            elif s.fallback:
490                result[s.name] = s.fallback
491        return result
492
493    async def fuse_async(self, signals):
494        """Async version."""
495        return self.fuse(signals)
496
497def standalone_function(x: int, y: int) -> int:
498    """Add two numbers."""
499    return x + y
500
501def test_something():
502    """A test function."""
503    assert True
504"#;
505
506    #[test]
507    fn test_parse_python_file_extracts_nodes() {
508        let path = PathBuf::from("brain/perception/signal_fusion.py");
509        let result = parse_python_file(&path, SAMPLE_PYTHON).expect("parse should succeed");
510
511        // Should have: file, module, class, 3 methods (__init__, fuse, fuse_async),
512        // standalone_function, test_something
513        assert!(
514            result.nodes.len() >= 7,
515            "Expected >= 7 nodes, got {}",
516            result.nodes.len()
517        );
518
519        // Check file node
520        let file = result.nodes.iter().find(|n| n.kind == CodeNodeKind::File);
521        assert!(file.is_some(), "should have file node");
522        assert_eq!(file.unwrap().name, "signal_fusion.py");
523
524        // Check module node
525        let module = result.nodes.iter().find(|n| n.kind == CodeNodeKind::Module);
526        assert!(module.is_some(), "should have module node");
527        assert_eq!(module.unwrap().name, "brain.perception.signal_fusion");
528
529        // Check class
530        let class = result
531            .nodes
532            .iter()
533            .find(|n| n.kind == CodeNodeKind::Class && n.name == "SignalFusion");
534        assert!(class.is_some(), "should have SignalFusion class");
535        let cls = class.unwrap();
536        assert!(cls.docstring.as_deref() == Some("Fuses signals from multiple sources."));
537
538        // Check methods
539        let fuse = result
540            .nodes
541            .iter()
542            .find(|n| n.kind == CodeNodeKind::Method && n.name == "fuse");
543        assert!(fuse.is_some(), "should have fuse method");
544        let fuse = fuse.unwrap();
545        assert!(fuse.signature.as_ref().unwrap().contains("def fuse"));
546        assert!(
547            fuse.cyclomatic_complexity.unwrap() >= 3,
548            "fuse has branches"
549        );
550
551        // Check standalone function
552        let standalone = result
553            .nodes
554            .iter()
555            .find(|n| n.kind == CodeNodeKind::Function && n.name == "standalone_function");
556        assert!(standalone.is_some(), "should have standalone_function");
557
558        // Check test function
559        let test_fn = result
560            .nodes
561            .iter()
562            .find(|n| n.kind == CodeNodeKind::Test && n.name == "test_something");
563        assert!(test_fn.is_some(), "should have test_something as Test kind");
564    }
565
566    #[test]
567    fn test_parse_extracts_imports() {
568        let path = PathBuf::from("test.py");
569        let result = parse_python_file(&path, SAMPLE_PYTHON).expect("parse should succeed");
570
571        assert!(
572            result.imports.len() >= 2,
573            "Expected >= 2 imports, got {}",
574            result.imports.len()
575        );
576
577        // Check for `import os`
578        let os_import = result.imports.iter().find(|i| i.module == "os");
579        assert!(os_import.is_some(), "should have os import");
580
581        // Check for `from pathlib import Path`
582        let pathlib = result.imports.iter().find(|i| i.module.contains("pathlib"));
583        assert!(pathlib.is_some(), "should have pathlib import");
584    }
585
586    #[test]
587    fn test_parse_extracts_docstrings() {
588        let path = PathBuf::from("test.py");
589        let result = parse_python_file(&path, SAMPLE_PYTHON).expect("parse should succeed");
590
591        // Module docstring
592        let module = result.nodes.iter().find(|n| n.kind == CodeNodeKind::Module);
593        assert_eq!(
594            module.unwrap().docstring.as_deref(),
595            Some("Module docstring.")
596        );
597    }
598
599    #[test]
600    fn test_parse_computes_body_hash() {
601        let path = PathBuf::from("test.py");
602        let result = parse_python_file(&path, SAMPLE_PYTHON).expect("parse should succeed");
603
604        // File node should have a body hash
605        let file = result.nodes.iter().find(|n| n.kind == CodeNodeKind::File);
606        assert!(file.unwrap().body_hash.is_some());
607
608        // All functions/methods should have body hashes
609        for node in &result.nodes {
610            if matches!(
611                node.kind,
612                CodeNodeKind::Function | CodeNodeKind::Method | CodeNodeKind::Test
613            ) {
614                assert!(
615                    node.body_hash.is_some(),
616                    "{} should have body_hash",
617                    node.id
618                );
619            }
620        }
621    }
622
623    #[test]
624    fn test_parse_containment_hierarchy() {
625        let path = PathBuf::from("brain/test.py");
626        let result = parse_python_file(&path, SAMPLE_PYTHON).expect("parse should succeed");
627
628        // Module's parent is file
629        let module = result
630            .nodes
631            .iter()
632            .find(|n| n.kind == CodeNodeKind::Module)
633            .unwrap();
634        let file = result
635            .nodes
636            .iter()
637            .find(|n| n.kind == CodeNodeKind::File)
638            .unwrap();
639        assert_eq!(module.parent_id.as_deref(), Some(file.id.as_str()));
640
641        // Class parent is module
642        let class = result
643            .nodes
644            .iter()
645            .find(|n| n.kind == CodeNodeKind::Class)
646            .unwrap();
647        assert_eq!(class.parent_id.as_deref(), Some(module.id.as_str()));
648
649        // Method parent is class
650        let method = result
651            .nodes
652            .iter()
653            .find(|n| n.kind == CodeNodeKind::Method)
654            .unwrap();
655        assert_eq!(method.parent_id.as_deref(), Some(class.id.as_str()));
656    }
657
658    #[test]
659    fn test_cyclomatic_complexity() {
660        let path = PathBuf::from("test.py");
661        let result = parse_python_file(&path, SAMPLE_PYTHON).expect("parse should succeed");
662
663        // standalone_function has no branches → complexity 1
664        let standalone = result
665            .nodes
666            .iter()
667            .find(|n| n.name == "standalone_function")
668            .unwrap();
669        assert_eq!(standalone.cyclomatic_complexity, Some(1));
670
671        // fuse has for, if+and, elif → complexity >= 4
672        let fuse = result.nodes.iter().find(|n| n.name == "fuse").unwrap();
673        assert!(
674            fuse.cyclomatic_complexity.unwrap() >= 4,
675            "fuse complexity should be >= 4, got {}",
676            fuse.cyclomatic_complexity.unwrap()
677        );
678    }
679
680    #[test]
681    fn test_sha256_hex() {
682        let hash = sha256_hex(b"hello");
683        assert_eq!(hash.len(), 64);
684        assert_eq!(
685            hash,
686            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
687        );
688    }
689
690    #[test]
691    fn test_parse_class_inheritance() {
692        let src = r#"
693class Child(Parent, Mixin):
694    """A child class."""
695    pass
696"#;
697        let path = PathBuf::from("test.py");
698        let result = parse_python_file(&path, src).expect("parse should succeed");
699
700        let class = result
701            .nodes
702            .iter()
703            .find(|n| n.kind == CodeNodeKind::Class && n.name == "Child")
704            .unwrap();
705        assert!(
706            class.signature.as_ref().unwrap().contains("Parent"),
707            "signature should include bases"
708        );
709        assert!(
710            class.signature.as_ref().unwrap().contains("Mixin"),
711            "signature should include all bases"
712        );
713    }
714}