1use crate::schema::{CodeNode, CodeNodeKind};
7use sha2::{Digest, Sha256};
8use std::path::Path;
9
10#[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#[derive(Debug)]
25pub struct ParseResult {
26 pub nodes: Vec<CodeNode>,
28 pub imports: Vec<ImportInfo>,
30}
31
32#[derive(Debug, Clone)]
34pub struct ImportInfo {
35 pub file_node_id: String,
37 pub module: String,
39 pub names: Vec<String>,
41 pub is_relative: bool,
43}
44
45pub 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 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 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, 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 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, 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 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
143fn 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 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 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 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 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
317fn 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 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 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
429fn compute_cyclomatic_complexity(node: &tree_sitter::Node, src: &[u8]) -> i32 {
431 let text = node_text(node, src);
432 let mut complexity = 1; for line in text.lines() {
435 let trimmed = line.trim();
436 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 complexity += trimmed.matches(" and ").count() as i32;
448 complexity += trimmed.matches(" or ").count() as i32;
449 }
450
451 complexity
452}
453
454pub 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 assert!(
514 result.nodes.len() >= 7,
515 "Expected >= 7 nodes, got {}",
516 result.nodes.len()
517 );
518
519 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 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 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 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 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 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 let os_import = result.imports.iter().find(|i| i.module == "os");
579 assert!(os_import.is_some(), "should have os import");
580
581 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 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 let file = result.nodes.iter().find(|n| n.kind == CodeNodeKind::File);
606 assert!(file.unwrap().body_hash.is_some());
607
608 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 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 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 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 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 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}