Skip to main content

nusy_codegraph/
python_parser.rs

1//! Python-specific tree-sitter parser — extracts CodeNodes with position metadata.
2//!
3//! This parser uses Python-specific `CodeNodeKind` variants (`PythonFunction`,
4//! `PythonClass`, etc.) rather than the generic variants used by `parser.rs`.
5//! All emitted nodes include position metadata (start_line, end_line, start_col,
6//! end_col, file_path, byte_offset) populated from tree-sitter node positions.
7//!
8//! # Differences from `parser.rs`
9//!
10//! | parser.rs (generic) | python_parser.rs (Python-specific) |
11//! |---|---|
12//! | `CodeNodeKind::Function` | `PythonFunction` or `PythonAsync` |
13//! | `CodeNodeKind::Method` | `PythonMethod`, `PythonProperty`, or `PythonAsync` |
14//! | `CodeNodeKind::Class` | `PythonClass` |
15//! | `CodeNodeKind::Module` | `PythonModule` |
16//! | No position metadata | All nodes have start/end line/col + byte_offset |
17//! | No decorator nodes | `PythonDecorator` nodes emitted |
18//! | No import nodes | `PythonImport` nodes emitted |
19//! | No lambda nodes | `PythonLambda` nodes (best-effort) |
20//!
21//! # Usage
22//!
23//! ```ignore
24//! let parser = PythonParser::new()?;
25//! let result = parser.parse_file(Path::new("brain/signal_fusion.py"), &source)?;
26//! println!("{} nodes extracted", result.nodes.len());
27//! ```
28
29use crate::parser::{ImportInfo, sha256_hex};
30use crate::schema::{CodeNode, CodeNodeKind};
31use std::path::Path;
32
33/// Errors from the Python-specific parser.
34#[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/// Result of parsing a Python file with the Python-specific parser.
47#[derive(Debug)]
48pub struct PythonParseResult {
49    /// All code nodes extracted from the file.
50    ///
51    /// Node kinds are Python-specific (`PythonFunction`, `PythonClass`, etc.).
52    /// All nodes include position metadata.
53    pub nodes: Vec<CodeNode>,
54    /// Import statements found (module path strings).
55    pub imports: Vec<ImportInfo>,
56}
57
58/// A Python-specific tree-sitter parser.
59///
60/// Wraps tree-sitter::Parser for Python. Caller constructs once, reuses across files.
61pub struct PythonParser {
62    parser: tree_sitter::Parser,
63}
64
65impl PythonParser {
66    /// Construct a new `PythonParser`.
67    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    /// Parse a Python source file into `PythonParseResult`.
75    ///
76    /// All emitted `CodeNode` records use Python-specific kinds and include
77    /// position metadata (`start_line`, `end_line`, `start_col`, `end_col`,
78    /// `file_path`, `byte_offset`).
79    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        // Module dotted name (e.g., brain.perception.signal_fusion)
97        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        // File node — language-agnostic (File) with position spanning whole file
104        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        // Module node — PythonModule
125        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        // Walk top-level AST children
146        let mut cursor = root.walk();
147        for child in root.children(&mut cursor) {
148            match child.kind() {
149                "function_definition" => {
150                    // tree-sitter-python represents `async def` as a function_definition
151                    // whose source text starts with "async " — detect it here.
152                    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                    // Note: decorated definitions at top level may contain import-like constructs
162                    // but tree-sitter parses these as function/class definitions only
163                }
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                        // Emit PythonImport node
171                        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
193// ─── Extraction helpers ──────────────────────────────────────────────────────
194
195/// Determine function kind based on context and decorators.
196///
197/// - `async def` → `PythonAsync`
198/// - `@property` on a method → `PythonProperty`
199/// - `def inside class` → `PythonMethod`
200/// - `def` at top-level or in module → `PythonFunction`
201fn 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        // Check for test function naming (consistent with parser.rs)
218        let name = node_child_text(node, "name", src).unwrap_or_default();
219        if name.starts_with("test_") || name == "test" {
220            // Keep as PythonFunction — tests in V12b use generic Test kind only in parser.rs
221            // PythonParser uses PythonFunction for all regular functions
222        }
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
275/// Handle a `decorated_definition` node — emits decorator nodes + the decorated item.
276fn 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    // Collect decorator names and emit PythonDecorator nodes
287    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            // Extract the decorator name (strip leading @)
293            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    // Find the inner function/class definition
325    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    // Extract methods from the class body
430    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
453/// Emit a `PythonImport` node from an import statement.
454fn 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
549// ─── AST utility helpers ─────────────────────────────────────────────────────
550
551fn 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// ─── Tests ───────────────────────────────────────────────────────────────────
666
667#[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        // File node uses generic File kind
719        let file = result.nodes.iter().find(|n| n.kind == CodeNodeKind::File);
720        assert!(file.is_some(), "should have File node");
721
722        // Module uses PythonModule
723        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        // Class uses PythonClass
730        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        // Regular function uses PythonFunction
738        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        // Async function uses PythonAsync
745        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        // @property method uses PythonProperty
752        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        // async method uses PythonAsync
759        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        // Regular method uses PythonMethod
769        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        // Every non-File node should have position metadata
783        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        // start_line must be <= end_line for all nodes
829        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        // The @property decorator should be present
858        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        // File node
885        let file = result
886            .nodes
887            .iter()
888            .find(|n| n.kind == CodeNodeKind::File)
889            .unwrap();
890        // Module node's parent is file
891        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        // Class parent is module
899        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        // Method parent is class
907        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        // The Python parser should not emit Function/Method/Class (generic) —
982        // only Python-specific kinds and File (which stays generic).
983        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}