1use crate::parser::{ImportInfo, sha256_hex};
30use crate::schema::{CodeNode, CodeNodeKind};
31use std::path::Path;
32
33#[derive(Debug, thiserror::Error)]
35pub enum PythonParserError {
36 #[error("IO error: {0}")]
37 Io(#[from] std::io::Error),
38
39 #[error("tree-sitter parse failed for {path}")]
40 ParseFailed { path: String },
41
42 #[error("tree-sitter language error: {0}")]
43 Language(#[from] tree_sitter::LanguageError),
44}
45
46#[derive(Debug)]
48pub struct PythonParseResult {
49 pub nodes: Vec<CodeNode>,
54 pub imports: Vec<ImportInfo>,
56}
57
58pub struct PythonParser {
62 parser: tree_sitter::Parser,
63}
64
65impl PythonParser {
66 pub fn new() -> Result<Self, PythonParserError> {
68 let mut parser = tree_sitter::Parser::new();
69 let language = tree_sitter_python::LANGUAGE;
70 parser.set_language(&language.into())?;
71 Ok(Self { parser })
72 }
73
74 pub fn parse_file(
80 &mut self,
81 path: &Path,
82 source: &str,
83 ) -> Result<PythonParseResult, PythonParserError> {
84 let tree =
85 self.parser
86 .parse(source, None)
87 .ok_or_else(|| PythonParserError::ParseFailed {
88 path: path.display().to_string(),
89 })?;
90
91 let path_str = path.display().to_string();
92 let file_id = format!("file:{path_str}");
93 let src = source.as_bytes();
94 let root = tree.root_node();
95
96 let mod_dotted = path_str
98 .strip_suffix(".py")
99 .unwrap_or(&path_str)
100 .replace(['/', '\\'], ".");
101 let mod_id = format!("mod:{path_str}");
102
103 let file_node = CodeNode {
105 id: file_id.clone(),
106 kind: CodeNodeKind::File,
107 parent_id: None,
108 name: path
109 .file_name()
110 .map(|n| n.to_string_lossy().to_string())
111 .unwrap_or_default(),
112 docstring: extract_module_docstring(&root, src),
113 body_hash: Some(sha256_hex(source.as_bytes())),
114 loc: Some(source.lines().count() as i32),
115 start_line: Some(1),
116 end_line: Some(source.lines().count() as u32),
117 start_col: Some(0),
118 end_col: Some(0),
119 file_path: Some(path_str.clone()),
120 byte_offset: Some(0),
121 ..Default::default()
122 };
123
124 let module_node = CodeNode {
126 id: mod_id.clone(),
127 kind: CodeNodeKind::PythonModule,
128 parent_id: Some(file_id.clone()),
129 name: mod_dotted,
130 docstring: file_node.docstring.clone(),
131 body_hash: file_node.body_hash.clone(),
132 loc: file_node.loc,
133 start_line: Some(1),
134 end_line: file_node.end_line,
135 start_col: Some(0),
136 end_col: Some(0),
137 file_path: Some(path_str.clone()),
138 byte_offset: Some(0),
139 ..Default::default()
140 };
141
142 let mut nodes = vec![file_node, module_node];
143 let mut imports = Vec::new();
144
145 let mut cursor = root.walk();
147 for child in root.children(&mut cursor) {
148 match child.kind() {
149 "function_definition" => {
150 let is_async = node_text(&child, src).trim_start().starts_with("async ");
153 let extracted =
154 extract_py_function(&child, src, &path_str, &mod_id, None, is_async);
155 nodes.extend(extracted);
156 }
157 "decorated_definition" => {
158 let (extracted_nodes, _extra_imports) =
159 extract_decorated(&child, src, &path_str, &mod_id, None);
160 nodes.extend(extracted_nodes);
161 }
164 "class_definition" => {
165 let class_nodes = extract_py_class(&child, src, &path_str, &mod_id);
166 nodes.extend(class_nodes);
167 }
168 "import_statement" => {
169 if let Some(imp) = extract_py_import(&child, src, &file_id) {
170 let node =
172 import_to_node(&child, src, &file_id, &imp.module, &path_str, false);
173 nodes.push(node);
174 imports.push(imp);
175 }
176 }
177 "import_from_statement" => {
178 if let Some(imp) = extract_py_import_from(&child, src, &file_id) {
179 let node =
180 import_to_node(&child, src, &file_id, &imp.module, &path_str, true);
181 nodes.push(node);
182 imports.push(imp);
183 }
184 }
185 _ => {}
186 }
187 }
188
189 Ok(PythonParseResult { nodes, imports })
190 }
191}
192
193fn function_kind(
202 node: &tree_sitter::Node,
203 src: &[u8],
204 in_class: bool,
205 is_async: bool,
206 decorators: &[String],
207) -> CodeNodeKind {
208 if is_async {
209 return CodeNodeKind::PythonAsync;
210 }
211 if in_class {
212 if decorators.iter().any(|d| d == "property") {
213 return CodeNodeKind::PythonProperty;
214 }
215 CodeNodeKind::PythonMethod
216 } else {
217 let name = node_child_text(node, "name", src).unwrap_or_default();
219 if name.starts_with("test_") || name == "test" {
220 }
223 CodeNodeKind::PythonFunction
224 }
225}
226
227fn extract_py_function(
228 node: &tree_sitter::Node,
229 src: &[u8],
230 path: &str,
231 parent_id: &str,
232 class_name: Option<&str>,
233 is_async: bool,
234) -> Vec<CodeNode> {
235 let name = node_child_text(node, "name", src).unwrap_or_default();
236 let in_class = class_name.is_some();
237 let kind = function_kind(node, src, in_class, is_async, &[]);
238
239 let id = match class_name {
240 Some(cls) => format!("pymethod:{path}::{cls}::{name}"),
241 None => format!("pyfunc:{path}::{name}"),
242 };
243
244 let signature = extract_py_signature(node, src, is_async);
245 let docstring = extract_py_docstring(node, src);
246 let body = node_text(node, src);
247 let body_hash = sha256_hex(body.as_bytes());
248 let loc = (node.end_position().row - node.start_position().row + 1) as i32;
249 let complexity = compute_cyclomatic_complexity(node, src);
250
251 let start = node.start_position();
252 let end = node.end_position();
253
254 vec![CodeNode {
255 id,
256 kind,
257 parent_id: Some(parent_id.to_string()),
258 name,
259 signature: Some(signature),
260 docstring,
261 body_hash: Some(body_hash),
262 body: Some(body),
263 loc: Some(loc),
264 cyclomatic_complexity: Some(complexity),
265 start_line: Some(start.row as u32 + 1),
266 end_line: Some(end.row as u32 + 1),
267 start_col: Some(start.column as u32),
268 end_col: Some(end.column as u32),
269 file_path: Some(path.to_string()),
270 byte_offset: Some(node.start_byte() as u64),
271 ..Default::default()
272 }]
273}
274
275fn extract_decorated(
277 node: &tree_sitter::Node,
278 src: &[u8],
279 path: &str,
280 parent_id: &str,
281 class_name: Option<&str>,
282) -> (Vec<CodeNode>, Vec<ImportInfo>) {
283 let mut nodes = Vec::new();
284 let imports = Vec::new();
285
286 let mut decorator_names: Vec<String> = Vec::new();
288 let mut cursor = node.walk();
289 for child in node.children(&mut cursor) {
290 if child.kind() == "decorator" {
291 let text = node_text(&child, src);
292 let name = text
294 .trim_start_matches('@')
295 .split('(')
296 .next()
297 .unwrap_or("")
298 .trim();
299 decorator_names.push(name.to_string());
300
301 let dec_id = format!("pydec:{path}::{name}");
302 let start = child.start_position();
303 let end = child.end_position();
304 nodes.push(CodeNode {
305 id: dec_id,
306 kind: CodeNodeKind::PythonDecorator,
307 parent_id: Some(parent_id.to_string()),
308 name: format!("@{name}"),
309 body: Some(text),
310 start_line: Some(start.row as u32 + 1),
311 end_line: Some(end.row as u32 + 1),
312 start_col: Some(start.column as u32),
313 end_col: Some(end.column as u32),
314 file_path: Some(path.to_string()),
315 byte_offset: Some(child.start_byte() as u64),
316 ..Default::default()
317 });
318 }
319 }
320
321 let in_class = class_name.is_some();
322 let is_property = decorator_names.iter().any(|d| d == "property");
323
324 let mut inner_cursor = node.walk();
326 for child in node.children(&mut inner_cursor) {
327 match child.kind() {
328 "function_definition" => {
329 let name = node_child_text(&child, "name", src).unwrap_or_default();
330 let is_async = node_text(&child, src).trim_start().starts_with("async ");
331
332 let kind = if is_async {
333 CodeNodeKind::PythonAsync
334 } else if in_class && is_property {
335 CodeNodeKind::PythonProperty
336 } else if in_class {
337 CodeNodeKind::PythonMethod
338 } else {
339 CodeNodeKind::PythonFunction
340 };
341
342 let id = match class_name {
343 Some(cls) => format!("pymethod:{path}::{cls}::{name}"),
344 None => format!("pyfunc:{path}::{name}"),
345 };
346
347 let signature = extract_py_signature(&child, src, is_async);
348 let docstring = extract_py_docstring(&child, src);
349 let body = node_text(&child, src);
350 let body_hash = sha256_hex(body.as_bytes());
351 let loc = (child.end_position().row - child.start_position().row + 1) as i32;
352 let complexity = compute_cyclomatic_complexity(&child, src);
353 let start = child.start_position();
354 let end = child.end_position();
355
356 nodes.push(CodeNode {
357 id,
358 kind,
359 parent_id: Some(parent_id.to_string()),
360 name,
361 signature: Some(signature),
362 docstring,
363 body_hash: Some(body_hash),
364 body: Some(body),
365 loc: Some(loc),
366 cyclomatic_complexity: Some(complexity),
367 start_line: Some(start.row as u32 + 1),
368 end_line: Some(end.row as u32 + 1),
369 start_col: Some(start.column as u32),
370 end_col: Some(end.column as u32),
371 file_path: Some(path.to_string()),
372 byte_offset: Some(child.start_byte() as u64),
373 ..Default::default()
374 });
375 }
376 "class_definition" => {
377 let class_nodes = extract_py_class(&child, src, path, parent_id);
378 nodes.extend(class_nodes);
379 }
380 _ => {}
381 }
382 }
383
384 (nodes, imports)
385}
386
387fn extract_py_class(
388 node: &tree_sitter::Node,
389 src: &[u8],
390 path: &str,
391 parent_id: &str,
392) -> Vec<CodeNode> {
393 let name = node_child_text(node, "name", src).unwrap_or_default();
394 let class_id = format!("pyclass:{path}::{name}");
395
396 let bases = extract_class_bases(node, src);
397 let signature = if bases.is_empty() {
398 format!("class {name}")
399 } else {
400 format!("class {name}({})", bases.join(", "))
401 };
402
403 let docstring = extract_py_docstring(node, src);
404 let body_text = node_text(node, src);
405 let body_hash = sha256_hex(body_text.as_bytes());
406 let loc = (node.end_position().row - node.start_position().row + 1) as i32;
407 let start = node.start_position();
408 let end = node.end_position();
409
410 let mut nodes = vec![CodeNode {
411 id: class_id.clone(),
412 kind: CodeNodeKind::PythonClass,
413 parent_id: Some(parent_id.to_string()),
414 name: name.clone(),
415 signature: Some(signature),
416 docstring,
417 body_hash: Some(body_hash),
418 body: Some(body_text),
419 loc: Some(loc),
420 start_line: Some(start.row as u32 + 1),
421 end_line: Some(end.row as u32 + 1),
422 start_col: Some(start.column as u32),
423 end_col: Some(end.column as u32),
424 file_path: Some(path.to_string()),
425 byte_offset: Some(node.start_byte() as u64),
426 ..Default::default()
427 }];
428
429 if let Some(body) = node.child_by_field_name("body") {
431 let mut cursor = body.walk();
432 for child in body.children(&mut cursor) {
433 match child.kind() {
434 "function_definition" => {
435 let is_async = node_text(&child, src).trim_start().starts_with("async ");
436 let method_nodes =
437 extract_py_function(&child, src, path, &class_id, Some(&name), is_async);
438 nodes.extend(method_nodes);
439 }
440 "decorated_definition" => {
441 let (method_nodes, _) =
442 extract_decorated(&child, src, path, &class_id, Some(&name));
443 nodes.extend(method_nodes);
444 }
445 _ => {}
446 }
447 }
448 }
449
450 nodes
451}
452
453fn import_to_node(
455 node: &tree_sitter::Node,
456 _src: &[u8],
457 parent_id: &str,
458 module: &str,
459 path: &str,
460 is_from: bool,
461) -> CodeNode {
462 let start = node.start_position();
463 let end = node.end_position();
464 let id = format!("pyimport:{path}::{}", module.replace(['.', '/'], "_"));
465 let name = if is_from {
466 format!("from {module} import ...")
467 } else {
468 format!("import {module}")
469 };
470 CodeNode {
471 id,
472 kind: CodeNodeKind::PythonImport,
473 parent_id: Some(parent_id.to_string()),
474 name,
475 start_line: Some(start.row as u32 + 1),
476 end_line: Some(end.row as u32 + 1),
477 start_col: Some(start.column as u32),
478 end_col: Some(end.column as u32),
479 file_path: Some(path.to_string()),
480 byte_offset: Some(node.start_byte() as u64),
481 ..Default::default()
482 }
483}
484
485fn extract_py_import(node: &tree_sitter::Node, src: &[u8], file_id: &str) -> Option<ImportInfo> {
486 let mut cursor = node.walk();
487 for child in node.children(&mut cursor) {
488 if child.kind() == "dotted_name" {
489 let module = node_text(&child, src);
490 return Some(ImportInfo {
491 file_node_id: file_id.to_string(),
492 module,
493 names: Vec::new(),
494 is_relative: false,
495 });
496 }
497 }
498 None
499}
500
501fn extract_py_import_from(
502 node: &tree_sitter::Node,
503 src: &[u8],
504 file_id: &str,
505) -> Option<ImportInfo> {
506 let full_text = node_text(node, src);
507 let is_relative = full_text
508 .trim_start_matches("from")
509 .trim_start()
510 .starts_with('.');
511
512 let parts: Vec<&str> = full_text.splitn(2, "import").collect();
513 if parts.len() < 2 {
514 return None;
515 }
516
517 let module = parts[0]
518 .trim()
519 .strip_prefix("from")
520 .unwrap_or(parts[0])
521 .trim()
522 .to_string();
523
524 let names: Vec<String> = parts[1]
525 .split(',')
526 .map(|s| {
527 s.trim()
528 .split(" as ")
529 .next()
530 .unwrap_or("")
531 .trim()
532 .to_string()
533 })
534 .filter(|s| !s.is_empty())
535 .collect();
536
537 if module.is_empty() && names.is_empty() {
538 return None;
539 }
540
541 Some(ImportInfo {
542 file_node_id: file_id.to_string(),
543 module,
544 names,
545 is_relative,
546 })
547}
548
549fn node_text(node: &tree_sitter::Node, src: &[u8]) -> String {
552 node.utf8_text(src).unwrap_or("").to_string()
553}
554
555fn node_child_text(node: &tree_sitter::Node, field: &str, src: &[u8]) -> Option<String> {
556 node.child_by_field_name(field).map(|n| node_text(&n, src))
557}
558
559fn extract_py_signature(node: &tree_sitter::Node, src: &[u8], is_async: bool) -> String {
560 let name = node_child_text(node, "name", src).unwrap_or_default();
561 let params = node
562 .child_by_field_name("parameters")
563 .map(|n| node_text(&n, src))
564 .unwrap_or_else(|| "()".to_string());
565 let ret = node
566 .child_by_field_name("return_type")
567 .map(|n| format!(" -> {}", node_text(&n, src)))
568 .unwrap_or_default();
569 let prefix = if is_async { "async def" } else { "def" };
570 format!("{prefix} {name}{params}{ret}")
571}
572
573fn extract_py_docstring(node: &tree_sitter::Node, src: &[u8]) -> Option<String> {
574 let body = node.child_by_field_name("body")?;
575 let mut cursor = body.walk();
576 let first_stmt = body.children(&mut cursor).next()?;
577
578 if first_stmt.kind() == "expression_statement" {
579 let mut inner_cursor = first_stmt.walk();
580 let expr = first_stmt.children(&mut inner_cursor).next()?;
581 if expr.kind() == "string" || expr.kind() == "concatenated_string" {
582 let text = node_text(&expr, src);
583 return Some(strip_docstring_quotes(&text));
584 }
585 }
586 None
587}
588
589fn extract_module_docstring(root: &tree_sitter::Node, src: &[u8]) -> Option<String> {
590 let mut cursor = root.walk();
591 for child in root.children(&mut cursor) {
592 if child.kind() == "expression_statement" {
593 let mut inner = child.walk();
594 if let Some(expr) = child.children(&mut inner).next()
595 && (expr.kind() == "string" || expr.kind() == "concatenated_string")
596 {
597 return Some(strip_docstring_quotes(&node_text(&expr, src)));
598 }
599 }
600 if child.kind() != "comment" && child.kind() != "expression_statement" {
601 break;
602 }
603 }
604 None
605}
606
607fn strip_docstring_quotes(s: &str) -> String {
608 let trimmed = s.trim();
609 if let Some(inner) = trimmed
610 .strip_prefix("\"\"\"")
611 .and_then(|s| s.strip_suffix("\"\"\""))
612 {
613 inner.trim().to_string()
614 } else if let Some(inner) = trimmed
615 .strip_prefix("'''")
616 .and_then(|s| s.strip_suffix("'''"))
617 {
618 inner.trim().to_string()
619 } else if let Some(inner) = trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
620 inner.trim().to_string()
621 } else if let Some(inner) = trimmed
622 .strip_prefix('\'')
623 .and_then(|s| s.strip_suffix('\''))
624 {
625 inner.trim().to_string()
626 } else {
627 trimmed.to_string()
628 }
629}
630
631fn extract_class_bases(node: &tree_sitter::Node, src: &[u8]) -> Vec<String> {
632 let Some(superclasses) = node.child_by_field_name("superclasses") else {
633 return Vec::new();
634 };
635 let mut bases = Vec::new();
636 let mut cursor = superclasses.walk();
637 for child in superclasses.children(&mut cursor) {
638 if child.kind() == "identifier" || child.kind() == "attribute" {
639 bases.push(node_text(&child, src));
640 }
641 }
642 bases
643}
644
645fn compute_cyclomatic_complexity(node: &tree_sitter::Node, src: &[u8]) -> i32 {
646 let text = node_text(node, src);
647 let mut complexity = 1;
648 for line in text.lines() {
649 let trimmed = line.trim();
650 if trimmed.starts_with("if ")
651 || trimmed.starts_with("elif ")
652 || trimmed.starts_with("for ")
653 || trimmed.starts_with("while ")
654 || trimmed.starts_with("except ")
655 || trimmed.starts_with("except:")
656 {
657 complexity += 1;
658 }
659 complexity += trimmed.matches(" and ").count() as i32;
660 complexity += trimmed.matches(" or ").count() as i32;
661 }
662 complexity
663}
664
665#[cfg(test)]
668mod tests {
669 use super::*;
670 use std::path::PathBuf;
671
672 const SAMPLE: &str = r#"
673"""Module docstring."""
674
675import os
676from pathlib import Path
677from brain.utils import helper
678
679class SignalFusion:
680 """Fuses signals."""
681
682 def __init__(self, config):
683 """Init."""
684 self.config = config
685
686 @property
687 def name(self):
688 """Property getter."""
689 return "SignalFusion"
690
691 async def fuse_async(self, signals):
692 """Async fuse."""
693 return signals
694
695 def fuse(self, signals: list) -> dict:
696 """Fuse all signals."""
697 result = {}
698 for s in signals:
699 if s.is_valid() and s.weight > 0:
700 result[s.name] = s.value
701 return result
702
703def standalone(x: int) -> int:
704 """Add."""
705 return x + 1
706
707async def async_top(x):
708 """Top-level async."""
709 return x
710"#;
711
712 #[test]
713 fn test_python_parser_extracts_python_specific_kinds() {
714 let mut parser = PythonParser::new().expect("parser init");
715 let path = PathBuf::from("brain/signal_fusion.py");
716 let result = parser.parse_file(&path, SAMPLE).expect("parse");
717
718 let file = result.nodes.iter().find(|n| n.kind == CodeNodeKind::File);
720 assert!(file.is_some(), "should have File node");
721
722 let module = result
724 .nodes
725 .iter()
726 .find(|n| n.kind == CodeNodeKind::PythonModule);
727 assert!(module.is_some(), "should have PythonModule node");
728
729 let class = result
731 .nodes
732 .iter()
733 .find(|n| n.kind == CodeNodeKind::PythonClass);
734 assert!(class.is_some(), "should have PythonClass node");
735 assert_eq!(class.unwrap().name, "SignalFusion");
736
737 let func = result
739 .nodes
740 .iter()
741 .find(|n| n.kind == CodeNodeKind::PythonFunction && n.name == "standalone");
742 assert!(func.is_some(), "should have PythonFunction for standalone");
743
744 let async_fn = result
746 .nodes
747 .iter()
748 .find(|n| n.kind == CodeNodeKind::PythonAsync && n.name == "async_top");
749 assert!(async_fn.is_some(), "should have PythonAsync for async_top");
750
751 let prop = result
753 .nodes
754 .iter()
755 .find(|n| n.kind == CodeNodeKind::PythonProperty && n.name == "name");
756 assert!(prop.is_some(), "should have PythonProperty for name");
757
758 let async_method = result
760 .nodes
761 .iter()
762 .find(|n| n.kind == CodeNodeKind::PythonAsync && n.name == "fuse_async");
763 assert!(
764 async_method.is_some(),
765 "should have PythonAsync for fuse_async"
766 );
767
768 let method = result
770 .nodes
771 .iter()
772 .find(|n| n.kind == CodeNodeKind::PythonMethod && n.name == "fuse");
773 assert!(method.is_some(), "should have PythonMethod for fuse");
774 }
775
776 #[test]
777 fn test_python_parser_emits_position_metadata() {
778 let mut parser = PythonParser::new().expect("parser init");
779 let path = PathBuf::from("brain/signal_fusion.py");
780 let result = parser.parse_file(&path, SAMPLE).expect("parse");
781
782 for node in &result.nodes {
784 assert!(
785 node.start_line.is_some(),
786 "node {} ({:?}) missing start_line",
787 node.id,
788 node.kind
789 );
790 assert!(
791 node.end_line.is_some(),
792 "node {} ({:?}) missing end_line",
793 node.id,
794 node.kind
795 );
796 assert!(
797 node.start_col.is_some(),
798 "node {} ({:?}) missing start_col",
799 node.id,
800 node.kind
801 );
802 assert!(
803 node.end_col.is_some(),
804 "node {} ({:?}) missing end_col",
805 node.id,
806 node.kind
807 );
808 assert_eq!(
809 node.file_path.as_deref(),
810 Some("brain/signal_fusion.py"),
811 "node {} missing file_path",
812 node.id
813 );
814 assert!(
815 node.byte_offset.is_some(),
816 "node {} missing byte_offset",
817 node.id
818 );
819 }
820 }
821
822 #[test]
823 fn test_python_parser_position_ordering() {
824 let mut parser = PythonParser::new().expect("parser init");
825 let path = PathBuf::from("test.py");
826 let result = parser.parse_file(&path, SAMPLE).expect("parse");
827
828 for node in &result.nodes {
830 if let (Some(sl), Some(el)) = (node.start_line, node.end_line) {
831 assert!(
832 sl <= el,
833 "node {} start_line {} > end_line {}",
834 node.id,
835 sl,
836 el
837 );
838 }
839 }
840 }
841
842 #[test]
843 fn test_python_parser_decorator_nodes() {
844 let mut parser = PythonParser::new().expect("parser init");
845 let path = PathBuf::from("test.py");
846 let result = parser.parse_file(&path, SAMPLE).expect("parse");
847
848 let decorators: Vec<_> = result
849 .nodes
850 .iter()
851 .filter(|n| n.kind == CodeNodeKind::PythonDecorator)
852 .collect();
853 assert!(
854 !decorators.is_empty(),
855 "should have at least one decorator node"
856 );
857 assert!(
859 decorators.iter().any(|d| d.name.contains("property")),
860 "should have @property decorator"
861 );
862 }
863
864 #[test]
865 fn test_python_parser_import_nodes() {
866 let mut parser = PythonParser::new().expect("parser init");
867 let path = PathBuf::from("test.py");
868 let result = parser.parse_file(&path, SAMPLE).expect("parse");
869
870 let imports: Vec<_> = result
871 .nodes
872 .iter()
873 .filter(|n| n.kind == CodeNodeKind::PythonImport)
874 .collect();
875 assert!(!imports.is_empty(), "should have import nodes");
876 }
877
878 #[test]
879 fn test_python_parser_containment_hierarchy() {
880 let mut parser = PythonParser::new().expect("parser init");
881 let path = PathBuf::from("brain/test.py");
882 let result = parser.parse_file(&path, SAMPLE).expect("parse");
883
884 let file = result
886 .nodes
887 .iter()
888 .find(|n| n.kind == CodeNodeKind::File)
889 .unwrap();
890 let module = result
892 .nodes
893 .iter()
894 .find(|n| n.kind == CodeNodeKind::PythonModule)
895 .unwrap();
896 assert_eq!(module.parent_id.as_deref(), Some(file.id.as_str()));
897
898 let class = result
900 .nodes
901 .iter()
902 .find(|n| n.kind == CodeNodeKind::PythonClass)
903 .unwrap();
904 assert_eq!(class.parent_id.as_deref(), Some(module.id.as_str()));
905
906 let method = result
908 .nodes
909 .iter()
910 .find(|n| n.kind == CodeNodeKind::PythonMethod)
911 .unwrap();
912 assert_eq!(method.parent_id.as_deref(), Some(class.id.as_str()));
913 }
914
915 #[test]
916 fn test_python_parser_import_info_extracted() {
917 let mut parser = PythonParser::new().expect("parser init");
918 let path = PathBuf::from("test.py");
919 let result = parser.parse_file(&path, SAMPLE).expect("parse");
920
921 assert!(
922 result.imports.len() >= 2,
923 "Expected >= 2 imports, got {}",
924 result.imports.len()
925 );
926 let os_import = result.imports.iter().find(|i| i.module == "os");
927 assert!(os_import.is_some(), "should have os import");
928 let pathlib = result.imports.iter().find(|i| i.module.contains("pathlib"));
929 assert!(pathlib.is_some(), "should have pathlib import");
930 }
931
932 #[test]
933 fn test_python_parser_docstrings() {
934 let mut parser = PythonParser::new().expect("parser init");
935 let path = PathBuf::from("test.py");
936 let result = parser.parse_file(&path, SAMPLE).expect("parse");
937
938 let module = result
939 .nodes
940 .iter()
941 .find(|n| n.kind == CodeNodeKind::PythonModule)
942 .unwrap();
943 assert_eq!(module.docstring.as_deref(), Some("Module docstring."));
944
945 let class = result
946 .nodes
947 .iter()
948 .find(|n| n.kind == CodeNodeKind::PythonClass)
949 .unwrap();
950 assert_eq!(class.docstring.as_deref(), Some("Fuses signals."));
951 }
952
953 #[test]
954 fn test_python_parser_class_inheritance() {
955 let src = r#"
956class Child(Parent, Mixin):
957 """A child class."""
958 pass
959"#;
960 let mut parser = PythonParser::new().expect("parser init");
961 let path = PathBuf::from("test.py");
962 let result = parser.parse_file(&path, src).expect("parse");
963
964 let class = result
965 .nodes
966 .iter()
967 .find(|n| n.kind == CodeNodeKind::PythonClass && n.name == "Child")
968 .unwrap();
969 assert!(
970 class.signature.as_ref().unwrap().contains("Parent"),
971 "signature should include bases"
972 );
973 assert!(
974 class.signature.as_ref().unwrap().contains("Mixin"),
975 "signature should include all bases"
976 );
977 }
978
979 #[test]
980 fn test_python_parser_no_generic_kinds_emitted() {
981 let mut parser = PythonParser::new().expect("parser init");
984 let path = PathBuf::from("test.py");
985 let result = parser.parse_file(&path, SAMPLE).expect("parse");
986
987 for node in &result.nodes {
988 assert!(
989 !matches!(
990 node.kind,
991 CodeNodeKind::Function
992 | CodeNodeKind::Method
993 | CodeNodeKind::Class
994 | CodeNodeKind::Module
995 ),
996 "node {} has generic kind {:?} — should use Python-specific kind",
997 node.id,
998 node.kind
999 );
1000 }
1001 }
1002}