Skip to main content

sqry_lang_python/relations/
graph_builder.rs

1use std::{collections::HashMap, path::Path};
2
3use sqry_core::graph::unified::StagingGraph;
4use sqry_core::graph::unified::build::GraphBuildHelper;
5use sqry_core::graph::unified::build::helper::CalleeKindHint;
6use sqry_core::graph::unified::edge::FfiConvention;
7use sqry_core::graph::unified::edge::kind::TypeOfContext;
8use sqry_core::graph::unified::node::NodeId as UnifiedNodeId;
9use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Span};
10use tree_sitter::{Node, Tree};
11
12use super::local_scopes;
13
14const DEFAULT_SCOPE_DEPTH: usize = 4;
15const STD_C_MODULES: &[&str] = &[
16    "_ctypes",
17    "_socket",
18    "_ssl",
19    "_hashlib",
20    "_json",
21    "_pickle",
22    "_struct",
23    "_sqlite3",
24    "_decimal",
25    "_lzma",
26    "_bz2",
27    "_zlib",
28    "_elementtree",
29    "_csv",
30    "_datetime",
31    "_heapq",
32    "_bisect",
33    "_random",
34    "_collections",
35    "_functools",
36    "_itertools",
37    "_operator",
38    "_io",
39    "_thread",
40    "_multiprocessing",
41    "_posixsubprocess",
42    "_asyncio",
43    "array",
44    "math",
45    "cmath",
46];
47const THIRD_PARTY_C_PACKAGES: &[&str] = &[
48    "numpy",
49    "pandas",
50    "scipy",
51    "sklearn",
52    "cv2",
53    "PIL",
54    "torch",
55    "tensorflow",
56    "lxml",
57    "psycopg2",
58    "MySQLdb",
59    "sqlite3",
60    "cryptography",
61    "bcrypt",
62    "regex",
63    "ujson",
64    "orjson",
65    "msgpack",
66    "greenlet",
67    "gevent",
68    "uvloop",
69];
70
71/// Graph builder for Python files using unified `CodeGraph` architecture.
72#[derive(Debug, Clone, Copy)]
73pub struct PythonGraphBuilder {
74    max_scope_depth: usize,
75}
76
77impl Default for PythonGraphBuilder {
78    fn default() -> Self {
79        Self {
80            max_scope_depth: DEFAULT_SCOPE_DEPTH,
81        }
82    }
83}
84
85impl PythonGraphBuilder {
86    #[must_use]
87    pub fn new(max_scope_depth: usize) -> Self {
88        Self { max_scope_depth }
89    }
90}
91
92impl GraphBuilder for PythonGraphBuilder {
93    fn build_graph(
94        &self,
95        tree: &Tree,
96        content: &[u8],
97        file: &Path,
98        staging: &mut StagingGraph,
99    ) -> GraphResult<()> {
100        // Create helper for staging graph population
101        let mut helper = GraphBuildHelper::new(staging, file, Language::Python);
102
103        // Build AST graph for call context tracking
104        let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
105            GraphBuilderError::ParseError {
106                span: Span::default(),
107                reason: e,
108            }
109        })?;
110
111        // Check if __all__ is defined in the module
112        let has_all = has_all_assignment(tree.root_node(), content);
113
114        // Build local variable scope tree
115        let mut scope_tree = local_scopes::build(tree.root_node(), content)?;
116
117        // Create recursion guard for tree walking
118        let recursion_limits =
119            sqry_core::config::RecursionLimits::load_or_default().map_err(|e| {
120                GraphBuilderError::ParseError {
121                    span: Span::default(),
122                    reason: format!("Failed to load recursion limits: {e}"),
123                }
124            })?;
125        let file_ops_depth = recursion_limits.effective_file_ops_depth().map_err(|e| {
126            GraphBuilderError::ParseError {
127                span: Span::default(),
128                reason: format!("Invalid file_ops_depth configuration: {e}"),
129            }
130        })?;
131        let mut guard =
132            sqry_core::query::security::RecursionGuard::new(file_ops_depth).map_err(|e| {
133                GraphBuilderError::ParseError {
134                    span: Span::default(),
135                    reason: format!("Failed to create recursion guard: {e}"),
136                }
137            })?;
138
139        // Walk tree to find functions, classes, methods, calls, and imports
140        walk_tree_for_graph(
141            tree.root_node(),
142            content,
143            &ast_graph,
144            &mut helper,
145            has_all,
146            &mut guard,
147            &mut scope_tree,
148        )?;
149
150        Ok(())
151    }
152
153    fn language(&self) -> Language {
154        Language::Python
155    }
156}
157
158/// Check if the module defines `__all__`.
159fn has_all_assignment(node: Node, content: &[u8]) -> bool {
160    let mut cursor = node.walk();
161    for child in node.children(&mut cursor) {
162        if child.kind() == "expression_statement" {
163            // Check for __all__ assignment
164            let assignment = child
165                .children(&mut child.walk())
166                .find(|c| c.kind() == "assignment" || c.kind() == "augmented_assignment");
167
168            if let Some(assignment) = assignment
169                && let Some(left) = assignment.child_by_field_name("left")
170                && let Ok(left_text) = left.utf8_text(content)
171                && left_text.trim() == "__all__"
172            {
173                return true;
174            }
175        }
176    }
177    false
178}
179
180/// Walk the tree and populate the staging graph.
181/// # Errors
182///
183/// Returns [`GraphBuilderError`] if graph operations fail or recursion depth exceeds the guard's limit.
184#[allow(clippy::too_many_lines)]
185fn walk_tree_for_graph(
186    node: Node,
187    content: &[u8],
188    ast_graph: &ASTGraph,
189    helper: &mut GraphBuildHelper,
190    has_all: bool,
191    guard: &mut sqry_core::query::security::RecursionGuard,
192    scope_tree: &mut local_scopes::PythonScopeTree,
193) -> GraphResult<()> {
194    guard.enter().map_err(|e| GraphBuilderError::ParseError {
195        span: Span::default(),
196        reason: format!("Recursion limit exceeded: {e}"),
197    })?;
198
199    match node.kind() {
200        "class_definition" => {
201            // Extract class name
202            if let Some(name_node) = node.child_by_field_name("name")
203                && let Ok(class_name) = name_node.utf8_text(content)
204            {
205                let span = span_from_node(node);
206
207                // Build qualified class name from scope
208                let qualified_name = class_name.to_string();
209
210                // Add class node
211                let class_id = helper.add_class(&qualified_name, Some(span));
212
213                // Process inheritance (base classes)
214                process_class_inheritance(node, content, class_id, helper);
215
216                // Note: Class body annotations are processed via normal recursion in walk_tree_for_graph
217
218                // Export public classes at module level (only if __all__ is not defined)
219                if !has_all && is_module_level(node) && is_public_name(class_name) {
220                    export_from_file_module(helper, class_id);
221                }
222            }
223        }
224        "expression_statement" => {
225            // Check for __all__ assignment (exports)
226            process_all_assignment(node, content, helper);
227
228            // Check for annotated assignments (type hints on variables)
229            process_annotated_assignment(node, content, ast_graph, helper);
230        }
231        "function_definition" => {
232            // Extract function context from AST graph
233            if let Some(call_context) = ast_graph.get_callable_context(node.id()) {
234                let span = span_from_node(node);
235
236                // Extract visibility from function name
237                let func_name = node
238                    .child_by_field_name("name")
239                    .and_then(|n| n.utf8_text(content).ok())
240                    .unwrap_or("");
241                let visibility = extract_visibility_from_name(func_name);
242
243                // Check if this is a property (has @property decorator)
244                let is_property = has_property_decorator(node, content);
245
246                // Extract return type annotation for signature (normalized — strips
247                // generics/unions/quotes for human-readable display).
248                let return_type = extract_return_type_annotation(node, content);
249
250                // Extract byte-exact source text of the return-type annotation for
251                // the `TypeOf { context: Return }` edge consumed by `returns:<Type>`
252                // queries. This text is intentionally NOT normalized — `Optional[int]`,
253                // `List[Dict[str, int]]`, `pd.DataFrame`, `"User"` are all preserved
254                // verbatim so byte-exact predicates work as documented.
255                let return_type_source = extract_return_type_source_text(node, content);
256
257                // Add function/method/property node
258                let function_id = if is_property && call_context.is_method {
259                    // Property node
260                    helper.add_node_with_visibility(
261                        &call_context.qualified_name,
262                        Some(span),
263                        sqry_core::graph::unified::node::NodeKind::Property,
264                        Some(visibility),
265                    )
266                } else if call_context.is_method {
267                    // Regular method with signature
268                    if return_type.is_some() {
269                        helper.add_method_with_signature(
270                            &call_context.qualified_name,
271                            Some(span),
272                            call_context.is_async,
273                            false, // Python doesn't have static methods in the same way
274                            Some(visibility),
275                            return_type.as_deref(),
276                        )
277                    } else {
278                        helper.add_method_with_visibility(
279                            &call_context.qualified_name,
280                            Some(span),
281                            call_context.is_async,
282                            false,
283                            Some(visibility),
284                        )
285                    }
286                } else {
287                    // Regular function with signature
288                    if return_type.is_some() {
289                        helper.add_function_with_signature(
290                            &call_context.qualified_name,
291                            Some(span),
292                            call_context.is_async,
293                            false, // Python doesn't have unsafe
294                            Some(visibility),
295                            return_type.as_deref(),
296                        )
297                    } else {
298                        helper.add_function_with_visibility(
299                            &call_context.qualified_name,
300                            Some(span),
301                            call_context.is_async,
302                            false,
303                            Some(visibility),
304                        )
305                    }
306                };
307
308                // Emit `TypeOf { context: Return }` edge for the return type
309                // annotation when present. Property nodes (Python `@property`) and
310                // un-annotated functions get no edge — `extract_return_type_source_text`
311                // returns `None` for `def foo():` (no `-> Type`).
312                //
313                // The type-text is byte-exact source from the annotation node so
314                // `returns:Optional[int]`, `returns:pd.DataFrame`, etc. work as
315                // documented. A paired Reference edge is also emitted to keep
316                // typeof/reference-edge invariants in sync with C# / Go / Kotlin /
317                // TypeScript plugins.
318                //
319                // The synthesized Type node is anchored at the return-type
320                // annotation's span (mirroring the Rust precedent in
321                // `sqry-lang-rust/src/relations/graph_builder.rs`) so downstream
322                // consumers (LSP `textDocument/documentSymbol`, MCP
323                // `get_document_symbols`) report a concrete source location
324                // rather than line 0.
325                if !(is_property && call_context.is_method)
326                    && let Some(annotation_text) = return_type_source.as_deref()
327                    && let Some(return_type_node) = node.child_by_field_name("return_type")
328                {
329                    let type_span = span_from_node(return_type_node);
330                    let type_id = helper.add_type(annotation_text, Some(type_span));
331                    helper.add_typeof_edge_with_context(
332                        function_id,
333                        type_id,
334                        Some(TypeOfContext::Return),
335                        Some(0),
336                        Some(call_context.qualified_name.as_str()),
337                    );
338                    helper.add_reference_edge(function_id, type_id);
339                }
340
341                // Check for HTTP route decorators (Flask/FastAPI)
342                if let Some((http_method, route_path)) = extract_route_decorator_info(node, content)
343                {
344                    let endpoint_name = format!("route::{http_method}::{route_path}");
345                    let endpoint_id = helper.add_endpoint(&endpoint_name, Some(span));
346                    helper.add_contains_edge(endpoint_id, function_id);
347                }
348
349                // Process parameters to create TypeOf and Reference edges for type hints
350                process_function_parameters(node, content, ast_graph, helper);
351
352                // Export public functions at module level (not methods, only if __all__ is not defined)
353                if !has_all
354                    && !call_context.is_method
355                    && is_module_level(node)
356                    && let Some(name_node) = node.child_by_field_name("name")
357                    && let Ok(func_name) = name_node.utf8_text(content)
358                    && is_public_name(func_name)
359                {
360                    export_from_file_module(helper, function_id);
361                }
362            }
363        }
364        "call" => {
365            // Check for FFI patterns first (ctypes, cffi)
366            let is_ffi = build_ffi_call_edge(ast_graph, node, content, helper)?;
367            if !is_ffi {
368                // Not an FFI call - build regular call edge
369                if let Ok(Some((caller_qname, callee_qname, argument_count, is_awaited))) =
370                    build_call_for_staging(ast_graph, node, content)
371                {
372                    // Ensure both nodes exist
373                    let call_context = ast_graph.get_callable_context(node.id());
374                    let _is_async = call_context.is_some_and(|c| c.is_async);
375
376                    let call_span = span_from_node(node);
377                    let source_id =
378                        helper.ensure_callee(&caller_qname, call_span, CalleeKindHint::Function);
379                    let target_id =
380                        helper.ensure_callee(&callee_qname, call_span, CalleeKindHint::Function);
381
382                    // Add call edge
383                    let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
384                    helper.add_call_edge_full_with_span(
385                        source_id,
386                        target_id,
387                        argument_count,
388                        is_awaited,
389                        vec![call_span],
390                    );
391                }
392            }
393        }
394        "import_statement" | "import_from_statement" => {
395            // Build import edge
396            if let Ok(Some((from_qname, to_qname))) =
397                build_import_for_staging(node, content, helper)
398            {
399                // Ensure both module nodes exist
400                let from_id = helper.add_import(&from_qname, None);
401                let to_id = helper.add_import(&to_qname, Some(span_from_node(node)));
402
403                // Add import edge
404                helper.add_import_edge(from_id, to_id);
405
406                // Check if this imports a known native C extension module
407                if is_native_extension_import(&to_qname) {
408                    build_native_import_ffi_edge(&to_qname, node, helper);
409                }
410            }
411        }
412        "identifier" => {
413            // Local variable reference tracking
414            local_scopes::handle_identifier_for_reference(node, content, scope_tree, helper);
415        }
416        _ => {}
417    }
418
419    // Recurse into children
420    let mut cursor = node.walk();
421    for child in node.children(&mut cursor) {
422        walk_tree_for_graph(
423            child, content, ast_graph, helper, has_all, guard, scope_tree,
424        )?;
425    }
426
427    guard.exit();
428    Ok(())
429}
430
431/// Build call edge information for the staging graph.
432fn build_call_for_staging(
433    ast_graph: &ASTGraph,
434    call_node: Node<'_>,
435    content: &[u8],
436) -> GraphResult<Option<(String, String, usize, bool)>> {
437    // Get or create module-level context for top-level calls
438    let module_context;
439    let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
440        ctx
441    } else {
442        // Create synthetic module-level context for top-level calls
443        module_context = CallContext {
444            qualified_name: "<module>".to_string(),
445            span: (0, content.len()),
446            is_async: false,
447            is_method: false,
448            class_name: None,
449        };
450        &module_context
451    };
452
453    let Some(callee_expr) = call_node.child_by_field_name("function") else {
454        return Ok(None);
455    };
456
457    let callee_text = callee_expr
458        .utf8_text(content)
459        .map_err(|_| GraphBuilderError::ParseError {
460            span: span_from_node(call_node),
461            reason: "failed to read call expression".to_string(),
462        })?
463        .trim()
464        .to_string();
465
466    if callee_text.is_empty() {
467        return Ok(None);
468    }
469
470    let callee_simple = simple_name(&callee_text);
471    if callee_simple.is_empty() {
472        return Ok(None);
473    }
474
475    // Derive qualified callee name with proper self resolution
476    let caller_qname = call_context.qualified_name();
477    let target_qname = if let Some(method_name) = callee_text.strip_prefix("self.") {
478        // Resolve self.method() to ClassName.method()
479        if let Some(class_name) = &call_context.class_name {
480            format!("{}.{}", class_name, simple_name(method_name))
481        } else {
482            callee_simple.to_string()
483        }
484    } else {
485        callee_simple.to_string()
486    };
487
488    let argument_count = count_arguments(call_node);
489    let is_awaited = is_awaited_call(call_node);
490    Ok(Some((
491        caller_qname,
492        target_qname,
493        argument_count,
494        is_awaited,
495    )))
496}
497
498/// Build import edge information for the staging graph.
499fn build_import_for_staging(
500    import_node: Node<'_>,
501    content: &[u8],
502    helper: &GraphBuildHelper,
503) -> GraphResult<Option<(String, String)>> {
504    // Extract the raw module name from the AST
505    let raw_module_name = if import_node.kind() == "import_statement" {
506        import_node
507            .child_by_field_name("name")
508            .and_then(|n| extract_module_name(n, content))
509    } else if import_node.kind() == "import_from_statement" {
510        import_node
511            .child_by_field_name("module_name")
512            .and_then(|n| extract_module_name(n, content))
513    } else {
514        None
515    };
516
517    // Handle relative imports with no module name
518    let module_name = if raw_module_name.is_none() && import_node.kind() == "import_from_statement"
519    {
520        if let Ok(import_text) = import_node.utf8_text(content) {
521            if let Some(from_idx) = import_text.find("from") {
522                if let Some(import_idx) = import_text.find("import") {
523                    let between = import_text[from_idx + 4..import_idx].trim();
524                    if between.starts_with('.') {
525                        Some(between.to_string())
526                    } else {
527                        None
528                    }
529                } else {
530                    None
531                }
532            } else {
533                None
534            }
535        } else {
536            None
537        }
538    } else {
539        raw_module_name
540    };
541
542    let Some(module_name) = module_name else {
543        return Ok(None);
544    };
545
546    if module_name.is_empty() {
547        return Ok(None);
548    }
549
550    // Resolve the import path to a canonical module identifier
551    let resolved_path = sqry_core::graph::resolve_python_import(
552        std::path::Path::new(helper.file_path()),
553        &module_name,
554        import_node.kind() == "import_from_statement",
555    )?;
556
557    // Return from/to qualified names
558    Ok(Some((helper.file_path().to_string(), resolved_path)))
559}
560
561fn span_from_node(node: Node<'_>) -> Span {
562    let start = node.start_position();
563    let end = node.end_position();
564    Span::new(
565        sqry_core::graph::node::Position::new(start.row, start.column),
566        sqry_core::graph::node::Position::new(end.row, end.column),
567    )
568}
569
570fn count_arguments(call_node: Node<'_>) -> usize {
571    call_node
572        .child_by_field_name("arguments")
573        .map_or(0, |args| {
574            args.named_children(&mut args.walk())
575                .filter(|child| {
576                    // Count actual arguments, not commas or parentheses
577                    !matches!(child.kind(), "," | "(" | ")")
578                })
579                .count()
580        })
581}
582
583fn is_awaited_call(call_node: Node<'_>) -> bool {
584    let mut current = call_node.parent();
585    while let Some(node) = current {
586        let kind = node.kind();
587        if kind == "await" || kind == "await_expression" {
588            return true;
589        }
590        current = node.parent();
591    }
592    false
593}
594
595/// Extract the simple name from a dotted identifier (for general call targets).
596///
597/// Takes the last component after splitting by dots.
598/// Used for qualified names like "module.func" → "func" or "obj.method" → "method".
599fn simple_name(qualified: &str) -> &str {
600    qualified.split('.').next_back().unwrap_or(qualified)
601}
602
603/// Extract a simple library name from an FFI library path.
604///
605/// For library paths with file extensions, extracts the base name before the extension.
606/// This prevents different libraries with the same extension (lib1.so, lib2.so) from
607/// colliding as duplicate "so" targets.
608///
609/// Handles:
610/// - Full paths: "/opt/v1.2/libfoo.so" → "libfoo"
611/// - Relative paths: "libs/lib1.so" → "lib1"
612/// - Versioned libs: "libc.so.6" → "libc"
613/// - Simple names: "kernel32" → "kernel32"
614/// - Variable refs: "$libname" → "$libname"
615fn ffi_library_simple_name(library_path: &str) -> String {
616    use std::path::Path;
617
618    // Strip directory components first (handles /opt/v1.2/libfoo.so)
619    let filename = Path::new(library_path)
620        .file_name()
621        .and_then(|f| f.to_str())
622        .unwrap_or(library_path);
623
624    // Handle versioned .so files first (libc.so.6 → libc)
625    if let Some(so_pos) = filename.find(".so.") {
626        return filename[..so_pos].to_string();
627    }
628
629    // Handle standard library extensions
630    if let Some(dot_pos) = filename.find('.') {
631        let extension = &filename[dot_pos + 1..];
632
633        // Check for known library extensions
634        if extension == "so" || extension == "dll" || extension == "dylib" {
635            // Extract base name before extension
636            return filename[..dot_pos].to_string();
637        }
638    }
639
640    // No library extension found - return filename as-is
641    filename.to_string()
642}
643
644/// Check if a name is public (does not start with underscore).
645///
646/// In Python, names starting with a single underscore are considered private by convention.
647/// Names starting with double underscores trigger name mangling in classes.
648/// Public names do not start with an underscore.
649fn is_public_name(name: &str) -> bool {
650    !name.starts_with('_')
651}
652
653/// Check if a node is at module level (direct child of the module body).
654///
655/// In tree-sitter Python AST, module-level items are direct children of the root "module" node.
656/// We check if the parent is "module" to determine module-level scope.
657fn is_module_level(node: Node<'_>) -> bool {
658    // Walk up the tree to find the immediate container
659    let mut current = node.parent();
660    while let Some(parent) = current {
661        match parent.kind() {
662            "module" => return true,
663            "function_definition" | "class_definition" => return false,
664            _ => current = parent.parent(),
665        }
666    }
667    false
668}
669
670/// Export a symbol from the file module.
671///
672/// File-level module name for exports/imports.
673/// Distinct from `<module>` to avoid conflicts with top-level call context.
674const FILE_MODULE_NAME: &str = "<file_module>";
675
676fn export_from_file_module(
677    helper: &mut GraphBuildHelper,
678    exported: sqry_core::graph::unified::node::NodeId,
679) {
680    let module_id = helper.add_module(FILE_MODULE_NAME, None);
681    helper.add_export_edge(module_id, exported);
682}
683
684/// Extract module name from a `dotted_name`, `aliased_import`, or `relative_import` node
685///
686/// For `import numpy as np`, the "name" field is an `aliased_import` node with structure:
687/// `aliased_import { name: dotted_name("numpy"), alias: identifier("np") }`
688/// We need to extract just "numpy", not "numpy as np".
689fn extract_module_name(node: Node<'_>, content: &[u8]) -> Option<String> {
690    // Handle aliased imports: `import numpy as np` -> extract "numpy"
691    if node.kind() == "aliased_import" {
692        // The "name" field of aliased_import contains the actual module name
693        return node
694            .child_by_field_name("name")
695            .and_then(|name_node| name_node.utf8_text(content).ok())
696            .map(std::string::ToString::to_string);
697    }
698
699    // Regular dotted_name or identifier
700    node.utf8_text(content)
701        .ok()
702        .map(std::string::ToString::to_string)
703}
704
705// ============================================================================
706// Exports - __all__ assignment handling
707// ============================================================================
708
709/// Process `__all__ = ['name1', 'name2']` assignments to create export edges.
710///
711/// Python's `__all__` list explicitly defines the public API of a module.
712/// Each name in the list gets an Export edge from the module to the exported symbol.
713fn process_all_assignment(node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
714    // expression_statement contains an assignment child
715    let assignment = node
716        .children(&mut node.walk())
717        .find(|child| child.kind() == "assignment" || child.kind() == "augmented_assignment");
718
719    let Some(assignment) = assignment else {
720        return;
721    };
722
723    // Check if left side is __all__
724    let left = assignment.child_by_field_name("left");
725    let Some(left) = left else {
726        return;
727    };
728
729    let Ok(left_text) = left.utf8_text(content) else {
730        return;
731    };
732
733    if left_text.trim() != "__all__" {
734        return;
735    }
736
737    // Get the right side (should be a list)
738    let right = assignment.child_by_field_name("right");
739    let Some(right) = right else {
740        return;
741    };
742
743    // Handle list or tuple literal (both valid for __all__)
744    if right.kind() == "list" || right.kind() == "tuple" {
745        process_all_list(right, content, helper);
746    }
747}
748
749/// Process a list/tuple of exported names from __all__.
750fn process_all_list(list_node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
751    for child in list_node.children(&mut list_node.walk()) {
752        // Look for string literals
753        if child.kind() == "string"
754            && let Some(export_name) = extract_string_content(child, content)
755            && !export_name.is_empty()
756        {
757            // Create a node for the exported symbol
758            // We use add_function here as a generic symbol; the actual type
759            // will be resolved later by cross-file analysis
760            let span = span_from_node(child);
761            let export_id = helper.add_function(&export_name, Some(span), false, false);
762
763            // Add export edge (Direct export, no alias for Python __all__)
764            export_from_file_module(helper, export_id);
765        }
766    }
767}
768
769/// Extract the content of a string literal node (removing quotes).
770fn extract_string_content(string_node: Node<'_>, content: &[u8]) -> Option<String> {
771    // String nodes contain string_content or string_start/string_content/string_end
772    // Try to get the full text and strip quotes
773    let Ok(text) = string_node.utf8_text(content) else {
774        return None;
775    };
776
777    let text = text.trim();
778
779    // Handle various Python string formats: 'x', "x", '''x''', """x""", r'x', etc.
780    let stripped = text
781        .trim_start_matches(|c: char| {
782            c == 'r'
783                || c == 'b'
784                || c == 'f'
785                || c == 'u'
786                || c == 'R'
787                || c == 'B'
788                || c == 'F'
789                || c == 'U'
790        })
791        .trim_start_matches("'''")
792        .trim_end_matches("'''")
793        .trim_start_matches("\"\"\"")
794        .trim_end_matches("\"\"\"")
795        .trim_start_matches('\'')
796        .trim_end_matches('\'')
797        .trim_start_matches('"')
798        .trim_end_matches('"');
799
800    Some(stripped.to_string())
801}
802
803// ============================================================================
804// OOP - Inheritance handling
805// ============================================================================
806
807/// Process class inheritance to create Inherits edges.
808///
809/// Python supports multiple inheritance: `class Child(Parent1, Parent2):`
810/// Each base class gets an Inherits edge from the child class.
811fn process_class_inheritance(
812    class_node: Node<'_>,
813    content: &[u8],
814    class_id: UnifiedNodeId,
815    helper: &mut GraphBuildHelper,
816) {
817    // In Python AST, base classes are in the superclasses field (argument_list)
818    // class_definition has a "superclasses" field containing argument_list
819    let superclasses = class_node.child_by_field_name("superclasses");
820
821    let Some(superclasses) = superclasses else {
822        return;
823    };
824
825    // argument_list contains the base classes
826    for child in superclasses.children(&mut superclasses.walk()) {
827        if child.kind() == "keyword_argument" {
828            // Skip keyword arguments like metaclass=ABCMeta.
829            continue;
830        }
831
832        match child.kind() {
833            "identifier" => {
834                // Simple base class: class Child(Parent):
835                if let Ok(base_name) = child.utf8_text(content) {
836                    let base_name = base_name.trim();
837                    if !base_name.is_empty() {
838                        let span = span_from_node(child);
839                        let base_id = helper.add_class(base_name, Some(span));
840                        helper.add_inherits_edge(class_id, base_id);
841                    }
842                }
843            }
844            "attribute" => {
845                // Qualified base class: class Child(module.Parent):
846                if let Ok(base_name) = child.utf8_text(content) {
847                    let base_name = base_name.trim();
848                    if !base_name.is_empty() {
849                        let span = span_from_node(child);
850                        let base_id = helper.add_class(base_name, Some(span));
851                        helper.add_inherits_edge(class_id, base_id);
852                    }
853                }
854            }
855            "call" => {
856                // Parameterized base class with call syntax: class Child(SomeBase(arg)):
857                // Extract the function being called
858                if let Some(func) = child.child_by_field_name("function")
859                    && let Ok(base_name) = func.utf8_text(content)
860                {
861                    let base_name = base_name.trim();
862                    if !base_name.is_empty() {
863                        let span = span_from_node(child);
864                        let base_id = helper.add_class(base_name, Some(span));
865                        helper.add_inherits_edge(class_id, base_id);
866                    }
867                }
868            }
869            "subscript" => {
870                // Generic base class: class Child(Generic[T]): or class Child(List[int]):
871                // Extract the base type from the subscript (value field)
872                if let Some(value) = child.child_by_field_name("value")
873                    && let Ok(base_name) = value.utf8_text(content)
874                {
875                    let base_name = base_name.trim();
876                    if !base_name.is_empty() {
877                        let span = span_from_node(child);
878                        let base_id = helper.add_class(base_name, Some(span));
879                        helper.add_inherits_edge(class_id, base_id);
880                    }
881                }
882            }
883            _ => {}
884        }
885    }
886}
887
888// ============================================================================
889// AST Graph - tracks callable contexts (functions, methods, classes)
890// ============================================================================
891
892#[derive(Debug, Clone)]
893struct CallContext {
894    qualified_name: String,
895    #[allow(dead_code)] // Reserved for scope analysis
896    span: (usize, usize),
897    is_async: bool,
898    is_method: bool,
899    class_name: Option<String>,
900}
901
902impl CallContext {
903    fn qualified_name(&self) -> String {
904        self.qualified_name.clone()
905    }
906}
907
908struct ASTGraph {
909    contexts: Vec<CallContext>,
910    node_to_context: HashMap<usize, usize>,
911}
912
913impl ASTGraph {
914    fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
915        let mut contexts = Vec::new();
916        let mut node_to_context = HashMap::new();
917        let mut scope_stack: Vec<String> = Vec::new();
918        let mut class_stack: Vec<String> = Vec::new();
919
920        walk_ast(
921            tree.root_node(),
922            content,
923            &mut contexts,
924            &mut node_to_context,
925            &mut scope_stack,
926            &mut class_stack,
927            max_depth,
928        )?;
929
930        Ok(Self {
931            contexts,
932            node_to_context,
933        })
934    }
935
936    #[allow(dead_code)] // Reserved for future context queries
937    fn contexts(&self) -> &[CallContext] {
938        &self.contexts
939    }
940
941    fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
942        self.node_to_context
943            .get(&node_id)
944            .and_then(|idx| self.contexts.get(*idx))
945    }
946}
947
948fn walk_ast(
949    node: Node,
950    content: &[u8],
951    contexts: &mut Vec<CallContext>,
952    node_to_context: &mut HashMap<usize, usize>,
953    scope_stack: &mut Vec<String>,
954    class_stack: &mut Vec<String>,
955    max_depth: usize,
956) -> Result<(), String> {
957    if scope_stack.len() > max_depth {
958        return Ok(());
959    }
960
961    match node.kind() {
962        "class_definition" => {
963            let name_node = node
964                .child_by_field_name("name")
965                .ok_or_else(|| "class_definition missing name".to_string())?;
966            let class_name = name_node
967                .utf8_text(content)
968                .map_err(|_| "failed to read class name".to_string())?;
969
970            // Build qualified class name
971            let qualified_class = if scope_stack.is_empty() {
972                class_name.to_string()
973            } else {
974                format!("{}.{}", scope_stack.join("."), class_name)
975            };
976
977            class_stack.push(qualified_class.clone());
978            scope_stack.push(class_name.to_string());
979
980            // Recurse into class body
981            if let Some(body) = node.child_by_field_name("body") {
982                let mut cursor = body.walk();
983                for child in body.children(&mut cursor) {
984                    walk_ast(
985                        child,
986                        content,
987                        contexts,
988                        node_to_context,
989                        scope_stack,
990                        class_stack,
991                        max_depth,
992                    )?;
993                }
994            }
995
996            class_stack.pop();
997            scope_stack.pop();
998        }
999        "function_definition" => {
1000            let name_node = node
1001                .child_by_field_name("name")
1002                .ok_or_else(|| "function_definition missing name".to_string())?;
1003            let func_name = name_node
1004                .utf8_text(content)
1005                .map_err(|_| "failed to read function name".to_string())?;
1006
1007            // Check if async
1008            let is_async = node
1009                .children(&mut node.walk())
1010                .any(|child| child.kind() == "async");
1011
1012            // Build qualified function name
1013            let qualified_func = if scope_stack.is_empty() {
1014                func_name.to_string()
1015            } else {
1016                format!("{}.{}", scope_stack.join("."), func_name)
1017            };
1018
1019            // Determine if this is a method (inside a class)
1020            let is_method = !class_stack.is_empty();
1021            let class_name = class_stack.last().cloned();
1022
1023            let context_idx = contexts.len();
1024            contexts.push(CallContext {
1025                qualified_name: qualified_func.clone(),
1026                span: (node.start_byte(), node.end_byte()),
1027                is_async,
1028                is_method,
1029                class_name,
1030            });
1031
1032            // Associate the function definition node itself with this context
1033            // This is required so walk_tree_for_graph can find the context
1034            node_to_context.insert(node.id(), context_idx);
1035
1036            // Associate all descendants with this context
1037            if let Some(body) = node.child_by_field_name("body") {
1038                associate_descendants(body, context_idx, node_to_context);
1039            }
1040
1041            scope_stack.push(func_name.to_string());
1042
1043            // Recurse into function body to find nested functions
1044            if let Some(body) = node.child_by_field_name("body") {
1045                let mut cursor = body.walk();
1046                for child in body.children(&mut cursor) {
1047                    walk_ast(
1048                        child,
1049                        content,
1050                        contexts,
1051                        node_to_context,
1052                        scope_stack,
1053                        class_stack,
1054                        max_depth,
1055                    )?;
1056                }
1057            }
1058
1059            scope_stack.pop();
1060        }
1061        _ => {
1062            // Recurse into children for other node types
1063            let mut cursor = node.walk();
1064            for child in node.children(&mut cursor) {
1065                walk_ast(
1066                    child,
1067                    content,
1068                    contexts,
1069                    node_to_context,
1070                    scope_stack,
1071                    class_stack,
1072                    max_depth,
1073                )?;
1074            }
1075        }
1076    }
1077
1078    Ok(())
1079}
1080
1081fn associate_descendants(
1082    node: Node,
1083    context_idx: usize,
1084    node_to_context: &mut HashMap<usize, usize>,
1085) {
1086    node_to_context.insert(node.id(), context_idx);
1087
1088    let mut stack = vec![node];
1089    while let Some(current) = stack.pop() {
1090        node_to_context.insert(current.id(), context_idx);
1091
1092        let mut cursor = current.walk();
1093        for child in current.children(&mut cursor) {
1094            stack.push(child);
1095        }
1096    }
1097}
1098
1099// ============================================================================
1100// FFI Detection - ctypes, cffi, and C extensions
1101// ============================================================================
1102
1103/// Build FFI edges for call expressions.
1104///
1105/// Detects Python FFI patterns:
1106/// - `ctypes.CDLL('libfoo.so')` / `ctypes.cdll.LoadLibrary('libfoo.so')`
1107/// - `ctypes.WinDLL('kernel32')` / `ctypes.windll.kernel32`
1108/// - `ctypes.PyDLL('libpython.so')`
1109/// - `cffi.FFI().dlopen('libfoo.so')`
1110/// - `ffi.dlopen('libfoo.so')`
1111///
1112/// Returns true if an FFI edge was created, false otherwise.
1113fn build_ffi_call_edge(
1114    ast_graph: &ASTGraph,
1115    call_node: Node<'_>,
1116    content: &[u8],
1117    helper: &mut GraphBuildHelper,
1118) -> GraphResult<bool> {
1119    let Some(callee_expr) = call_node.child_by_field_name("function") else {
1120        return Ok(false);
1121    };
1122
1123    let callee_text = callee_expr
1124        .utf8_text(content)
1125        .map_err(|_| GraphBuilderError::ParseError {
1126            span: span_from_node(call_node),
1127            reason: "failed to read call expression".to_string(),
1128        })?
1129        .trim();
1130
1131    // Check for ctypes library loading patterns
1132    if is_ctypes_load_call(callee_text) {
1133        return Ok(build_ctypes_ffi_edge(
1134            ast_graph,
1135            call_node,
1136            content,
1137            callee_text,
1138            helper,
1139        ));
1140    }
1141
1142    // Check for cffi dlopen patterns
1143    if is_cffi_dlopen_call(callee_text) {
1144        return Ok(build_cffi_ffi_edge(ast_graph, call_node, content, helper));
1145    }
1146
1147    Ok(false)
1148}
1149
1150/// Check if the callee is a ctypes library loading function.
1151///
1152/// Narrowed patterns to reduce false positives - only match explicit ctypes paths.
1153/// Previous: `callee_text.ends_with(".LoadLibrary")` matched too broadly.
1154///
1155/// Note: `ctypes.cdll.kernel32` style attribute access patterns are not detected
1156/// because they're attribute access (not function calls). We only detect explicit
1157/// library loading function calls like CDLL('lib.so').
1158fn is_ctypes_load_call(callee_text: &str) -> bool {
1159    // Direct ctypes constructors (fully qualified)
1160    callee_text == "ctypes.CDLL"
1161        || callee_text == "ctypes.WinDLL"
1162        || callee_text == "ctypes.OleDLL"
1163        || callee_text == "ctypes.PyDLL"
1164        // ctypes.cdll/windll LoadLibrary (fully qualified)
1165        || callee_text == "ctypes.cdll.LoadLibrary"
1166        || callee_text == "ctypes.windll.LoadLibrary"
1167        || callee_text == "ctypes.oledll.LoadLibrary"
1168        // After `from ctypes import *` or `from ctypes import CDLL, etc.`
1169        || callee_text == "CDLL"
1170        || callee_text == "WinDLL"
1171        || callee_text == "OleDLL"
1172        || callee_text == "PyDLL"
1173        // After `from ctypes import cdll` or similar
1174        || callee_text == "cdll.LoadLibrary"
1175        || callee_text == "windll.LoadLibrary"
1176        || callee_text == "oledll.LoadLibrary"
1177}
1178
1179/// Check if the callee is a cffi dlopen function.
1180///
1181/// Narrowed patterns to reduce false positives - only match known cffi patterns.
1182/// Previous: `callee_text.ends_with(".dlopen")` matched too broadly.
1183fn is_cffi_dlopen_call(callee_text: &str) -> bool {
1184    // Common cffi FFI variable names followed by dlopen
1185    callee_text == "ffi.dlopen"
1186        || callee_text == "cffi.dlopen"
1187        || callee_text == "_ffi.dlopen"
1188        // FFI() constructor followed by dlopen (chained call)
1189        // This pattern typically appears as: FFI().dlopen('lib.so')
1190        // In tree-sitter, the callee text would be the method access part
1191        // After `from cffi import FFI`
1192        || callee_text == "FFI().dlopen"
1193}
1194
1195/// Build FFI edge for ctypes library loading.
1196fn build_ctypes_ffi_edge(
1197    ast_graph: &ASTGraph,
1198    call_node: Node<'_>,
1199    content: &[u8],
1200    callee_text: &str,
1201    helper: &mut GraphBuildHelper,
1202) -> bool {
1203    // Get caller context
1204    let caller_id = get_ffi_caller_node_id(ast_graph, call_node, content, helper);
1205
1206    // Determine FFI convention based on the ctypes type
1207    let convention = if callee_text.contains("WinDLL")
1208        || callee_text.contains("windll")
1209        || callee_text.contains("OleDLL")
1210    {
1211        FfiConvention::Stdcall
1212    } else {
1213        FfiConvention::C
1214    };
1215
1216    // Try to extract library name from first argument
1217    let library_name = extract_ffi_library_name(call_node, content)
1218        .unwrap_or_else(|| "ctypes::unknown".to_string());
1219
1220    let ffi_name = format!("native::{}", ffi_library_simple_name(&library_name));
1221    let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(call_node)));
1222
1223    // Add FFI edge
1224    helper.add_ffi_edge(caller_id, ffi_node_id, convention);
1225
1226    true
1227}
1228
1229/// Build FFI edge for cffi dlopen.
1230fn build_cffi_ffi_edge(
1231    ast_graph: &ASTGraph,
1232    call_node: Node<'_>,
1233    content: &[u8],
1234    helper: &mut GraphBuildHelper,
1235) -> bool {
1236    // Get caller context
1237    let caller_id = get_ffi_caller_node_id(ast_graph, call_node, content, helper);
1238
1239    // Try to extract library name from first argument
1240    let library_name =
1241        extract_ffi_library_name(call_node, content).unwrap_or_else(|| "cffi::unknown".to_string());
1242
1243    let ffi_name = format!("native::{}", ffi_library_simple_name(&library_name));
1244    let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(call_node)));
1245
1246    // cffi uses C calling convention
1247    helper.add_ffi_edge(caller_id, ffi_node_id, FfiConvention::C);
1248
1249    true
1250}
1251
1252/// Get the caller node ID for FFI edges.
1253fn get_ffi_caller_node_id(
1254    ast_graph: &ASTGraph,
1255    node: Node<'_>,
1256    content: &[u8],
1257    helper: &mut GraphBuildHelper,
1258) -> UnifiedNodeId {
1259    let module_context;
1260    let call_context = if let Some(ctx) = ast_graph.get_callable_context(node.id()) {
1261        ctx
1262    } else {
1263        module_context = CallContext {
1264            qualified_name: "<module>".to_string(),
1265            span: (0, content.len()),
1266            is_async: false,
1267            is_method: false,
1268            class_name: None,
1269        };
1270        &module_context
1271    };
1272
1273    let caller_span = Some(Span::from_bytes(call_context.span.0, call_context.span.1));
1274    helper.ensure_function(
1275        &call_context.qualified_name(),
1276        caller_span,
1277        call_context.is_async,
1278        false,
1279    )
1280}
1281
1282/// Extract the library name from the first argument of a call.
1283fn extract_ffi_library_name(call_node: Node<'_>, content: &[u8]) -> Option<String> {
1284    let args = call_node.child_by_field_name("arguments")?;
1285
1286    let mut cursor = args.walk();
1287    let first_arg = args
1288        .children(&mut cursor)
1289        .find(|child| !matches!(child.kind(), "(" | ")" | ","))?;
1290
1291    // Handle string literals
1292    if first_arg.kind() == "string" {
1293        return extract_string_content(first_arg, content);
1294    }
1295
1296    // Handle identifiers (variable names) - we can't resolve them statically
1297    if first_arg.kind() == "identifier" {
1298        let text = first_arg.utf8_text(content).ok()?;
1299        return Some(format!("${}", text.trim())); // Mark as variable reference
1300    }
1301
1302    None
1303}
1304
1305/// Check if an import statement imports a known native extension module.
1306///
1307/// This detects patterns like:
1308/// - `import numpy` (known C extension)
1309/// - `from numpy import array` (known C extension)
1310/// - `import _sqlite3` (private C module)
1311fn is_native_extension_import(module_name: &str) -> bool {
1312    // Private C modules (underscore prefix)
1313    if module_name.starts_with('_') && !module_name.starts_with("__") {
1314        return true;
1315    }
1316
1317    // Check against known modules
1318    let base_module = module_name.split('.').next().unwrap_or(module_name);
1319
1320    STD_C_MODULES.contains(&base_module) || THIRD_PARTY_C_PACKAGES.contains(&base_module)
1321}
1322
1323/// Build FFI edge for native extension import.
1324fn build_native_import_ffi_edge(
1325    module_name: &str,
1326    import_node: Node<'_>,
1327    helper: &mut GraphBuildHelper,
1328) {
1329    // Create module node for the importing file
1330    let file_path = helper.file_path().to_string();
1331    let importer_id = helper.add_module(&file_path, None);
1332
1333    // Create node for the native module
1334    let ffi_name = format!("native::{}", simple_name(module_name));
1335    let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(import_node)));
1336
1337    // Add FFI edge (C convention for Python C extensions)
1338    helper.add_ffi_edge(importer_id, ffi_node_id, FfiConvention::C);
1339}
1340
1341// ============================================================================
1342// HTTP Route Endpoint Detection - Flask/FastAPI decorators
1343// ============================================================================
1344
1345/// HTTP methods recognized in route decorators.
1346const ROUTE_METHOD_NAMES: &[&str] = &["get", "post", "put", "delete", "patch"];
1347
1348/// Receiver names recognized as route-capable objects.
1349///
1350/// `Flask` uses `app` or `blueprint`, `FastAPI` uses `app` or `router`.
1351const ROUTE_RECEIVER_NAMES: &[&str] = &["app", "router", "blueprint"];
1352
1353/// Extract HTTP route information from Flask/FastAPI-style decorators on a function.
1354///
1355/// Checks whether the given `function_definition` node is wrapped in a `decorated_definition`
1356/// and whether any of its decorators match known route patterns:
1357///
1358/// - `@app.route('/path')` or `@app.route('/path', methods=['GET'])` -- GET by default
1359/// - `@app.get('/path')` / `@app.post('/path')` / `@app.put('/path')` / etc.
1360/// - `@router.get('/path')` (`FastAPI`)
1361/// - `@blueprint.route('/path')` (Flask blueprints)
1362///
1363/// Returns `Some((method, path))` where `method` is the uppercased HTTP method and
1364/// `path` is the route path string, or `None` if no route decorator is found.
1365fn extract_route_decorator_info(func_node: Node<'_>, content: &[u8]) -> Option<(String, String)> {
1366    // The function_definition must be a child of decorated_definition
1367    let parent = func_node.parent()?;
1368    if parent.kind() != "decorated_definition" {
1369        return None;
1370    }
1371
1372    // Iterate through decorator children of the decorated_definition
1373    let mut cursor = parent.walk();
1374    for child in parent.children(&mut cursor) {
1375        if child.kind() != "decorator" {
1376            continue;
1377        }
1378
1379        let Ok(decorator_text) = child.utf8_text(content) else {
1380            continue;
1381        };
1382        let decorator_text = decorator_text.trim();
1383
1384        // Strip the leading '@'
1385        let without_at = decorator_text.strip_prefix('@')?;
1386
1387        // Try to parse as a route decorator
1388        if let Some(result) = parse_route_decorator_text(without_at) {
1389            return Some(result);
1390        }
1391    }
1392
1393    None
1394}
1395
1396/// Parse a single decorator text (without the leading `@`) to extract route information.
1397///
1398/// Recognized patterns:
1399/// - `app.route('/path')` or `app.route('/path', methods=['POST'])`
1400/// - `app.get('/path')` / `router.post('/path')` / `blueprint.delete('/path')`
1401///
1402/// Returns `Some((HTTP_METHOD, path))` or `None`.
1403fn parse_route_decorator_text(text: &str) -> Option<(String, String)> {
1404    // Split into receiver.method and argument portion
1405    // e.g. "app.route('/api/users')" -> ("app.route", "'/api/users')")
1406    let paren_pos = text.find('(')?;
1407    let accessor = &text[..paren_pos];
1408    let args_text = &text[paren_pos + 1..];
1409
1410    // Split accessor into receiver and method_name
1411    let dot_pos = accessor.rfind('.')?;
1412    let receiver = &accessor[..dot_pos];
1413    let method_name = &accessor[dot_pos + 1..];
1414
1415    // Check that the receiver is a known route-capable object.
1416    // Allow dotted receivers (e.g., "api.v1") as long as the final segment matches.
1417    let receiver_base = receiver.rsplit('.').next().unwrap_or(receiver);
1418    if !ROUTE_RECEIVER_NAMES.contains(&receiver_base) {
1419        return None;
1420    }
1421
1422    // Extract the route path from the first argument (string literal)
1423    let path = extract_path_from_decorator_args(args_text)?;
1424
1425    // Determine the HTTP method
1426    let method_lower = method_name.to_ascii_lowercase();
1427    if ROUTE_METHOD_NAMES.contains(&method_lower.as_str()) {
1428        // Direct method decorator: @app.get('/path') -> GET
1429        return Some((method_lower.to_ascii_uppercase(), path));
1430    }
1431
1432    if method_lower == "route" {
1433        // Generic route decorator: @app.route('/path', methods=['POST'])
1434        let http_method = extract_method_from_route_args(args_text);
1435        return Some((http_method, path));
1436    }
1437
1438    None
1439}
1440
1441/// Extract the route path string from decorator arguments text.
1442///
1443/// The `args_text` parameter is everything after the opening parenthesis of the decorator call,
1444/// e.g. `'/api/users', methods=['GET'])` or `"/api/items")`.
1445///
1446/// Returns the path string with quotes stripped, or `None` if no path is found.
1447fn extract_path_from_decorator_args(args_text: &str) -> Option<String> {
1448    let trimmed = args_text.trim();
1449
1450    // Find the first string literal (single or double quoted)
1451    let (quote_char, start_pos) = {
1452        let single_pos = trimmed.find('\'');
1453        let double_pos = trimmed.find('"');
1454        match (single_pos, double_pos) {
1455            (Some(s), Some(d)) => {
1456                if s < d {
1457                    ('\'', s)
1458                } else {
1459                    ('"', d)
1460                }
1461            }
1462            (Some(s), None) => ('\'', s),
1463            (None, Some(d)) => ('"', d),
1464            (None, None) => return None,
1465        }
1466    };
1467
1468    // Find the closing quote
1469    let after_open = start_pos + 1;
1470    let close_pos = trimmed[after_open..].find(quote_char)?;
1471    let path = &trimmed[after_open..after_open + close_pos];
1472
1473    if path.is_empty() {
1474        return None;
1475    }
1476
1477    Some(path.to_string())
1478}
1479
1480/// Extract the HTTP method from `@app.route('/path', methods=['POST'])` style arguments.
1481///
1482/// Looks for a `methods=` keyword argument containing a list of method strings.
1483/// If found, returns the first method in uppercase. Otherwise defaults to `"GET"`.
1484fn extract_method_from_route_args(args_text: &str) -> String {
1485    // Look for 'methods' keyword in the arguments
1486    let Some(methods_pos) = args_text.find("methods") else {
1487        return "GET".to_string();
1488    };
1489
1490    // Find the opening bracket after 'methods='
1491    let after_methods = &args_text[methods_pos..];
1492    let Some(bracket_pos) = after_methods.find('[') else {
1493        return "GET".to_string();
1494    };
1495
1496    let after_bracket = &after_methods[bracket_pos + 1..];
1497
1498    // Find the first string literal inside the bracket
1499    let method_str = extract_first_string_literal(after_bracket);
1500    match method_str {
1501        Some(m) => m.to_ascii_uppercase(),
1502        None => "GET".to_string(),
1503    }
1504}
1505
1506/// Extract the first single- or double-quoted string literal from the given text.
1507fn extract_first_string_literal(text: &str) -> Option<String> {
1508    let trimmed = text.trim();
1509
1510    let (quote_char, start_pos) = {
1511        let single_pos = trimmed.find('\'');
1512        let double_pos = trimmed.find('"');
1513        match (single_pos, double_pos) {
1514            (Some(s), Some(d)) => {
1515                if s < d {
1516                    ('\'', s)
1517                } else {
1518                    ('"', d)
1519                }
1520            }
1521            (Some(s), None) => ('\'', s),
1522            (None, Some(d)) => ('"', d),
1523            (None, None) => return None,
1524        }
1525    };
1526
1527    let after_open = start_pos + 1;
1528    let close_pos = trimmed[after_open..].find(quote_char)?;
1529    let literal = &trimmed[after_open..after_open + close_pos];
1530
1531    if literal.is_empty() {
1532        return None;
1533    }
1534
1535    Some(literal.to_string())
1536}
1537
1538// ============================================================================
1539// Property Detection - @property decorator
1540// ============================================================================
1541
1542/// Check if a function definition has a `@property` decorator.
1543///
1544/// Python AST structure for decorated functions:
1545/// ```python
1546/// @property
1547/// def name(self):
1548///     return self._name
1549/// ```
1550///
1551/// The tree-sitter AST wraps the `function_definition` in a `decorated_definition` node:
1552/// ```text
1553/// (block
1554///   (decorated_definition
1555///     decorator: (decorator "@property")
1556///     definition: (function_definition)))
1557/// ```
1558fn has_property_decorator(func_node: Node<'_>, content: &[u8]) -> bool {
1559    // The function_definition is a child of decorated_definition
1560    let Some(parent) = func_node.parent() else {
1561        return false;
1562    };
1563
1564    // Check if parent is decorated_definition
1565    if parent.kind() != "decorated_definition" {
1566        return false;
1567    }
1568
1569    // Look for @property decorator in the decorated_definition
1570    let mut cursor = parent.walk();
1571    for child in parent.children(&mut cursor) {
1572        if child.kind() == "decorator" {
1573            // Extract decorator text
1574            if let Ok(decorator_text) = child.utf8_text(content) {
1575                let decorator_text = decorator_text.trim();
1576                // Match @property or @property()
1577                if decorator_text == "@property"
1578                    || decorator_text.starts_with("@property(")
1579                    || decorator_text.starts_with("@property (")
1580                {
1581                    return true;
1582                }
1583            }
1584        }
1585    }
1586
1587    false
1588}
1589
1590/// Extract visibility from Python identifier based on naming convention.
1591///
1592/// Python uses naming conventions for visibility:
1593/// - `__name` (dunder) -> private (name mangling)
1594/// - `_name` (single underscore) -> protected/internal
1595/// - `name` -> public
1596fn extract_visibility_from_name(name: &str) -> &'static str {
1597    if name.starts_with("__") && !name.ends_with("__") {
1598        "private"
1599    } else if name.starts_with('_') {
1600        "protected"
1601    } else {
1602        "public"
1603    }
1604}
1605
1606// ============================================================================
1607// Type Hint Processing - TypeOf and Reference Edges
1608// ============================================================================
1609
1610/// Find the containing scope (function/class) for a node to create scope-qualified names.
1611///
1612/// This walks up the AST to find the nearest enclosing function or class definition.
1613/// Returns:
1614/// - Empty string for module-level
1615/// - Class name for class-level (e.g., "`MyClass`")
1616/// - Function qualified name for function-level (e.g., "MyClass.method" or "process")
1617fn find_containing_scope(node: Node<'_>, content: &[u8], ast_graph: &ASTGraph) -> String {
1618    let mut current = node;
1619    let mut found_class_name: Option<String> = None;
1620
1621    // Walk up the tree to find enclosing function or class
1622    while let Some(parent) = current.parent() {
1623        match parent.kind() {
1624            "function_definition" => {
1625                // Found enclosing function - get its qualified name
1626                if let Some(ctx) = ast_graph.get_callable_context(parent.id()) {
1627                    return ctx.qualified_name.clone();
1628                }
1629            }
1630            "class_definition" => {
1631                // Remember the class name but continue walking up
1632                // to check if we're inside a function within this class
1633                if found_class_name.is_none() {
1634                    // Extract class name directly from node
1635                    if let Some(name_node) = parent.child_by_field_name("name")
1636                        && let Ok(class_name) = name_node.utf8_text(content)
1637                    {
1638                        found_class_name = Some(class_name.to_string());
1639                    }
1640                }
1641            }
1642            _ => {}
1643        }
1644        current = parent;
1645    }
1646
1647    // If we found a class but no enclosing function, it's a class attribute
1648    found_class_name.unwrap_or_default()
1649}
1650
1651/// Extract return type annotation from a function definition.
1652///
1653/// Python AST structure:
1654/// ```python
1655/// def foo() -> int:  # return_type field contains type annotation
1656/// ```
1657fn extract_return_type_annotation(func_node: Node<'_>, content: &[u8]) -> Option<String> {
1658    let return_type_node = func_node.child_by_field_name("return_type")?;
1659    extract_type_from_node(return_type_node, content)
1660}
1661
1662/// Extract the byte-exact source text of a function's `-> Type` annotation.
1663///
1664/// Unlike [`extract_return_type_annotation`], this returns the raw annotation
1665/// text verbatim — no quote stripping, no union flattening, no generic-base
1666/// extraction. This is the form consumed by `returns:<TypeName>` predicates,
1667/// which match the byte-exact qualified name of the target Type node.
1668///
1669/// Returns `None` when the function has no `-> Type` annotation (e.g.
1670/// `def foo():`), in which case no Return edge is emitted.
1671///
1672/// Examples (input → returned text):
1673/// - `def foo() -> int:` → `Some("int")`
1674/// - `def foo() -> Optional[int]:` → `Some("Optional[int]")`
1675/// - `def foo() -> List[Dict[str, int]]:` → `Some("List[Dict[str, int]]")`
1676/// - `def foo() -> pd.DataFrame:` → `Some("pd.DataFrame")`
1677/// - `async def foo() -> AsyncIterator[int]:` → `Some("AsyncIterator[int]")`
1678/// - `def foo() -> "User":` → `Some("\"User\"")`
1679/// - `def foo():` → `None`
1680fn extract_return_type_source_text(func_node: Node<'_>, content: &[u8]) -> Option<String> {
1681    let return_type_node = func_node.child_by_field_name("return_type")?;
1682    let text = return_type_node.utf8_text(content).ok()?.trim();
1683    if text.is_empty() {
1684        None
1685    } else {
1686        Some(text.to_string())
1687    }
1688}
1689
1690/// Process function parameters to create `TypeOf` and Reference edges for type hints.
1691///
1692/// Handles:
1693/// - `def foo(x: int, y: str):` - typed parameters
1694/// - `def foo(self, x: int):` - skips self/cls
1695/// - `def foo(x: List[int]):` - extracts base type from generics
1696fn process_function_parameters(
1697    func_node: Node<'_>,
1698    content: &[u8],
1699    ast_graph: &ASTGraph,
1700    helper: &mut GraphBuildHelper,
1701) {
1702    let Some(params_node) = func_node.child_by_field_name("parameters") else {
1703        return;
1704    };
1705
1706    // Get the qualified name of the containing function/method for scope qualification
1707    let scope_prefix = ast_graph
1708        .get_callable_context(func_node.id())
1709        .map_or("", |ctx| ctx.qualified_name.as_str());
1710
1711    // Iterate through parameters in the parameter_list
1712    for param in params_node.children(&mut params_node.walk()) {
1713        // Python tree-sitter uses "typed_parameter" and "typed_default_parameter"
1714        // but we need to handle the actual structure
1715        match param.kind() {
1716            "typed_parameter" | "typed_default_parameter" => {
1717                process_typed_parameter(param, content, scope_prefix, helper);
1718            }
1719            // Untyped parameter - check if it has a type annotation in parent context
1720            // For now, skip (no type hint)
1721            // Default parameter without type - skip
1722            "identifier" | "default_parameter" => {}
1723            _ => {
1724                // Other parameter types - try to process if they have type annotations
1725                // This handles various parameter structures
1726                if param.child_by_field_name("type").is_some() {
1727                    process_typed_parameter(param, content, scope_prefix, helper);
1728                }
1729            }
1730        }
1731    }
1732}
1733
1734/// Process a single typed parameter node.
1735///
1736/// Creates scope-qualified variable names to prevent cross-scope type contamination.
1737/// Format: `<scope_prefix>:<param_name>` (e.g., `MyClass.method:x` or `process:x`)
1738fn process_typed_parameter(
1739    param: Node<'_>,
1740    content: &[u8],
1741    scope_prefix: &str,
1742    helper: &mut GraphBuildHelper,
1743) {
1744    // Extract parameter name (could be in "name" field or as identifier child)
1745    let param_name = if let Some(name_node) = param.child_by_field_name("name") {
1746        name_node.utf8_text(content).ok()
1747    } else {
1748        // Fallback: look for identifier child
1749        param
1750            .children(&mut param.walk())
1751            .find(|c| c.kind() == "identifier")
1752            .and_then(|n| n.utf8_text(content).ok())
1753    };
1754
1755    let Some(param_name) = param_name else {
1756        return;
1757    };
1758
1759    // Skip self and cls (special method parameters)
1760    if param_name == "self" || param_name == "cls" {
1761        return;
1762    }
1763
1764    // Extract type annotation
1765    let Some(type_node) = param.child_by_field_name("type") else {
1766        return;
1767    };
1768
1769    let Some(type_name) = extract_type_from_node(type_node, content) else {
1770        return;
1771    };
1772
1773    // Create scope-qualified parameter name to prevent cross-scope contamination
1774    // Format: <scope_prefix>:<param_name> (e.g., "MyClass.method:x" or "process:x")
1775    let qualified_param_name = if scope_prefix.is_empty() {
1776        // Top-level function parameter
1777        format!(":{param_name}")
1778    } else {
1779        format!("{scope_prefix}:{param_name}")
1780    };
1781
1782    // Create parameter variable node with qualified name
1783    let param_id = helper.add_variable(&qualified_param_name, Some(span_from_node(param)));
1784
1785    // Create type node
1786    let type_id = helper.add_type(&type_name, None);
1787
1788    // Add TypeOf and Reference edges
1789    helper.add_typeof_edge(param_id, type_id);
1790    helper.add_reference_edge(param_id, type_id);
1791}
1792
1793/// Process annotated assignments to create `TypeOf` and Reference edges.
1794///
1795/// Handles:
1796/// - `user: User = get_user()` - annotated assignment with value
1797/// - `count: int` - annotated assignment without value
1798/// - `items: List[str] = []` - generic types
1799fn process_annotated_assignment(
1800    expr_stmt_node: Node<'_>,
1801    content: &[u8],
1802    ast_graph: &ASTGraph,
1803    helper: &mut GraphBuildHelper,
1804) {
1805    // Get the containing scope for scope qualification
1806    // For assignments, we need to find the enclosing function/class
1807    let scope_prefix = find_containing_scope(expr_stmt_node, content, ast_graph);
1808
1809    // Look for expression_statement containing an assignment
1810    for child in expr_stmt_node.children(&mut expr_stmt_node.walk()) {
1811        if child.kind() == "assignment" {
1812            process_typed_assignment(child, content, &scope_prefix, helper);
1813        }
1814    }
1815}
1816
1817/// Process a typed assignment node (shared logic for variables and class attributes).
1818///
1819/// Creates scope-qualified variable names to prevent cross-scope type contamination.
1820fn process_typed_assignment(
1821    assignment_node: Node<'_>,
1822    content: &[u8],
1823    scope_prefix: &str,
1824    helper: &mut GraphBuildHelper,
1825) {
1826    // Check if this is a typed assignment by looking for type annotation
1827    // In Python, annotated assignments look like: name: type = value
1828    // The AST structure is: assignment { left: identifier, type: type, right: expression }
1829
1830    let Some(left) = assignment_node.child_by_field_name("left") else {
1831        return;
1832    };
1833
1834    let Some(type_node) = assignment_node.child_by_field_name("type") else {
1835        return;
1836    };
1837
1838    // Extract variable name
1839    let Ok(var_name) = left.utf8_text(content) else {
1840        return;
1841    };
1842
1843    // Extract type
1844    let Some(type_name) = extract_type_from_node(type_node, content) else {
1845        return;
1846    };
1847
1848    // Create scope-qualified variable name to prevent cross-scope contamination
1849    // For class attributes (module-level or class-level), use simple name
1850    // For function-local variables, use qualified name
1851    let qualified_var_name = if scope_prefix.is_empty() {
1852        // Module-level variable
1853        var_name.to_string()
1854    } else if scope_prefix.contains('.') && !scope_prefix.contains(':') {
1855        // Class attribute (scope_prefix is class name without function)
1856        format!("{scope_prefix}.{var_name}")
1857    } else {
1858        // Function-local variable
1859        format!("{scope_prefix}:{var_name}")
1860    };
1861
1862    // Create variable node with qualified name
1863    let var_id = helper.add_variable(&qualified_var_name, Some(span_from_node(assignment_node)));
1864
1865    // Create type node
1866    let type_id = helper.add_type(&type_name, None);
1867
1868    // Add TypeOf and Reference edges
1869    helper.add_typeof_edge(var_id, type_id);
1870    helper.add_reference_edge(var_id, type_id);
1871}
1872
1873/// Extract type name from a type annotation node.
1874///
1875/// Handles:
1876/// - Simple types: `int`, `str`, `bool`
1877/// - Generic types: `List[int]` → extract base type `List`
1878/// - Optional types: `Optional[User]` → extract base type `Optional`
1879/// - Qualified types: `module.Type` → extract full qualified name
1880/// - Forward references: `"User"` → `User` (strips quotes)
1881/// - PEP 604 unions: `User | None` → `User` (extracts left-most base type)
1882fn extract_type_from_node(type_node: Node<'_>, content: &[u8]) -> Option<String> {
1883    match type_node.kind() {
1884        "type" => {
1885            // The "type" node wraps the actual type - recurse into first child
1886            type_node
1887                .named_child(0)
1888                .and_then(|child| extract_type_from_node(child, content))
1889        }
1890        "identifier" => {
1891            // Simple type: int, str, User
1892            type_node.utf8_text(content).ok().map(String::from)
1893        }
1894        "string" => {
1895            // Forward reference: "User" -> User
1896            // Strip surrounding quotes from string literal annotations
1897            let text = type_node.utf8_text(content).ok()?;
1898            let trimmed = text.trim();
1899
1900            // Remove quotes: "User" or 'User' -> User
1901            if (trimmed.starts_with('"') && trimmed.ends_with('"'))
1902                || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
1903            {
1904                let unquoted = &trimmed[1..trimmed.len() - 1];
1905                // Handle potential unions inside string: "User | None" -> "User"
1906                Some(normalize_union_type(unquoted))
1907            } else {
1908                Some(trimmed.to_string())
1909            }
1910        }
1911        "binary_operator" => {
1912            // PEP 604 union: User | None -> User
1913            // Extract left operand as the primary type
1914            if let Some(left) = type_node.child_by_field_name("left") {
1915                extract_type_from_node(left, content)
1916            } else {
1917                // Fallback: extract text and normalize
1918                type_node
1919                    .utf8_text(content)
1920                    .ok()
1921                    .map(|text| normalize_union_type(text.trim()))
1922            }
1923        }
1924        "generic_type" | "subscript" => {
1925            // Generic type: List[int], Dict[str, int], Optional[User]
1926            // Extract base type (before the brackets)
1927            // Structure: subscript { value: identifier, subscript: [...] }
1928            if let Some(value_node) = type_node.child_by_field_name("value") {
1929                extract_type_from_node(value_node, content)
1930            } else {
1931                // Fallback: try first named child
1932                type_node
1933                    .named_child(0)
1934                    .and_then(|child| extract_type_from_node(child, content))
1935                    .or_else(|| {
1936                        // Last resort: extract the full text and take the base type
1937                        type_node.utf8_text(content).ok().and_then(|text| {
1938                            // Extract base type from "List[str]" -> "List"
1939                            text.split('[').next().map(|s| s.trim().to_string())
1940                        })
1941                    })
1942            }
1943        }
1944        "attribute" => {
1945            // Qualified type: module.Type or package.module.Type
1946            type_node.utf8_text(content).ok().map(String::from)
1947        }
1948        "list" | "tuple" | "set" => {
1949            // Collection literals (though rare in type annotations)
1950            type_node.utf8_text(content).ok().map(String::from)
1951        }
1952        _ => {
1953            // Fallback: try to extract text from any other node
1954            // For unknown node types, try to extract intelligently
1955            let text = type_node.utf8_text(content).ok()?;
1956            let trimmed = text.trim();
1957
1958            // If it looks like a generic type, extract base type
1959            if trimmed.contains('[') {
1960                trimmed.split('[').next().map(|s| s.trim().to_string())
1961            } else {
1962                // Check for union syntax
1963                Some(normalize_union_type(trimmed))
1964            }
1965        }
1966    }
1967}
1968
1969/// Normalize union types by extracting the left-most/primary type.
1970///
1971/// Examples:
1972/// - `User | None` → `User`
1973/// - `str | int` → `str`
1974/// - `Optional[User]` → `Optional[User]` (unchanged, not a union)
1975fn normalize_union_type(type_str: &str) -> String {
1976    if let Some(pipe_pos) = type_str.find('|') {
1977        // Extract left side of union and trim
1978        type_str[..pipe_pos].trim().to_string()
1979    } else {
1980        type_str.to_string()
1981    }
1982}
1983
1984#[cfg(test)]
1985mod tests {
1986    use super::*;
1987
1988    #[test]
1989    fn test_simple_name_extracts_dotted_identifiers() {
1990        // General dotted identifier handling (for call targets)
1991        assert_eq!(simple_name("module.func"), "func");
1992        assert_eq!(simple_name("obj.method"), "method");
1993        assert_eq!(simple_name("package.module.func"), "func");
1994        assert_eq!(simple_name("self.helper"), "helper");
1995
1996        // No dots - return as-is
1997        assert_eq!(simple_name("function"), "function");
1998        assert_eq!(simple_name(""), "");
1999    }
2000
2001    #[test]
2002    fn test_ffi_library_simple_name_extracts_library_base_names() {
2003        // Standard shared library names
2004        assert_eq!(ffi_library_simple_name("libfoo.so"), "libfoo");
2005        assert_eq!(ffi_library_simple_name("lib1.so"), "lib1");
2006        assert_eq!(ffi_library_simple_name("lib2.so"), "lib2");
2007
2008        // Different extensions
2009        assert_eq!(ffi_library_simple_name("kernel32.dll"), "kernel32");
2010        assert_eq!(ffi_library_simple_name("libSystem.dylib"), "libSystem");
2011
2012        // Versioned shared libraries (libc.so.6)
2013        assert_eq!(ffi_library_simple_name("libc.so.6"), "libc");
2014
2015        // No extension - return as-is
2016        assert_eq!(ffi_library_simple_name("kernel32"), "kernel32");
2017        assert_eq!(ffi_library_simple_name("numpy"), "numpy");
2018
2019        // Variable references (prefixed with $)
2020        assert_eq!(ffi_library_simple_name("$libname"), "$libname");
2021
2022        // Edge cases
2023        assert_eq!(ffi_library_simple_name(""), "");
2024        assert_eq!(ffi_library_simple_name("lib.so"), "lib");
2025    }
2026
2027    #[test]
2028    fn test_ffi_library_simple_name_prevents_duplicate_edges() {
2029        // This was the bug: lib1.so and lib2.so both became "so"
2030        let name1 = ffi_library_simple_name("lib1.so");
2031        let name2 = ffi_library_simple_name("lib2.so");
2032
2033        // They should be different
2034        assert_ne!(
2035            name1, name2,
2036            "lib1.so and lib2.so must produce different simple names"
2037        );
2038        assert_eq!(name1, "lib1");
2039        assert_eq!(name2, "lib2");
2040    }
2041
2042    #[test]
2043    fn test_ffi_library_simple_name_handles_directory_paths() {
2044        // Full paths with directories containing dots (Codex finding)
2045        assert_eq!(ffi_library_simple_name("/opt/v1.2/libfoo.so"), "libfoo");
2046        assert_eq!(
2047            ffi_library_simple_name("/usr/lib/x86_64-linux-gnu/libc.so.6"),
2048            "libc"
2049        );
2050        assert_eq!(ffi_library_simple_name("libs/lib1.so"), "lib1");
2051
2052        // Relative paths
2053        assert_eq!(ffi_library_simple_name("./libs/kernel32.dll"), "kernel32");
2054        assert_eq!(
2055            ffi_library_simple_name("../lib/libSystem.dylib"),
2056            "libSystem"
2057        );
2058    }
2059
2060    // ====================================================================
2061    // Route decorator parsing unit tests
2062    // ====================================================================
2063
2064    #[test]
2065    fn test_parse_route_decorator_app_route_default_get() {
2066        let result = parse_route_decorator_text("app.route('/api/users')");
2067        assert_eq!(result, Some(("GET".to_string(), "/api/users".to_string())));
2068    }
2069
2070    #[test]
2071    fn test_parse_route_decorator_app_route_with_methods_post() {
2072        let result = parse_route_decorator_text("app.route('/api/users', methods=['POST'])");
2073        assert_eq!(result, Some(("POST".to_string(), "/api/users".to_string())));
2074    }
2075
2076    #[test]
2077    fn test_parse_route_decorator_app_route_with_methods_put_double_quotes() {
2078        let result = parse_route_decorator_text("app.route(\"/api/items\", methods=[\"PUT\"])");
2079        assert_eq!(result, Some(("PUT".to_string(), "/api/items".to_string())));
2080    }
2081
2082    #[test]
2083    fn test_parse_route_decorator_app_get() {
2084        let result = parse_route_decorator_text("app.get('/api/users')");
2085        assert_eq!(result, Some(("GET".to_string(), "/api/users".to_string())));
2086    }
2087
2088    #[test]
2089    fn test_parse_route_decorator_app_post() {
2090        let result = parse_route_decorator_text("app.post('/api/items')");
2091        assert_eq!(result, Some(("POST".to_string(), "/api/items".to_string())));
2092    }
2093
2094    #[test]
2095    fn test_parse_route_decorator_app_put() {
2096        let result = parse_route_decorator_text("app.put('/api/items/1')");
2097        assert_eq!(
2098            result,
2099            Some(("PUT".to_string(), "/api/items/1".to_string()))
2100        );
2101    }
2102
2103    #[test]
2104    fn test_parse_route_decorator_app_delete() {
2105        let result = parse_route_decorator_text("app.delete('/api/items/1')");
2106        assert_eq!(
2107            result,
2108            Some(("DELETE".to_string(), "/api/items/1".to_string()))
2109        );
2110    }
2111
2112    #[test]
2113    fn test_parse_route_decorator_app_patch() {
2114        let result = parse_route_decorator_text("app.patch('/api/items/1')");
2115        assert_eq!(
2116            result,
2117            Some(("PATCH".to_string(), "/api/items/1".to_string()))
2118        );
2119    }
2120
2121    #[test]
2122    fn test_parse_route_decorator_router_get_fastapi() {
2123        let result = parse_route_decorator_text("router.get('/api/users')");
2124        assert_eq!(result, Some(("GET".to_string(), "/api/users".to_string())));
2125    }
2126
2127    #[test]
2128    fn test_parse_route_decorator_router_post_fastapi() {
2129        let result = parse_route_decorator_text("router.post('/api/items')");
2130        assert_eq!(result, Some(("POST".to_string(), "/api/items".to_string())));
2131    }
2132
2133    #[test]
2134    fn test_parse_route_decorator_blueprint_route() {
2135        let result = parse_route_decorator_text("blueprint.route('/health')");
2136        assert_eq!(result, Some(("GET".to_string(), "/health".to_string())));
2137    }
2138
2139    #[test]
2140    fn test_parse_route_decorator_unknown_receiver_returns_none() {
2141        // "server" is not a recognized receiver
2142        let result = parse_route_decorator_text("server.get('/api/users')");
2143        assert_eq!(result, None);
2144    }
2145
2146    #[test]
2147    fn test_parse_route_decorator_unknown_method_returns_none() {
2148        // "options" is not in the ROUTE_METHOD_NAMES list and is not "route"
2149        let result = parse_route_decorator_text("app.options('/api/users')");
2150        assert_eq!(result, None);
2151    }
2152
2153    #[test]
2154    fn test_parse_route_decorator_no_parens_returns_none() {
2155        let result = parse_route_decorator_text("app.route");
2156        assert_eq!(result, None);
2157    }
2158
2159    #[test]
2160    fn test_parse_route_decorator_no_dot_returns_none() {
2161        let result = parse_route_decorator_text("route('/api/users')");
2162        assert_eq!(result, None);
2163    }
2164
2165    #[test]
2166    fn test_extract_path_from_decorator_args_single_quotes() {
2167        let result = extract_path_from_decorator_args("'/api/users')");
2168        assert_eq!(result, Some("/api/users".to_string()));
2169    }
2170
2171    #[test]
2172    fn test_extract_path_from_decorator_args_double_quotes() {
2173        let result = extract_path_from_decorator_args("\"/api/items\")");
2174        assert_eq!(result, Some("/api/items".to_string()));
2175    }
2176
2177    #[test]
2178    fn test_extract_path_from_decorator_args_empty_returns_none() {
2179        let result = extract_path_from_decorator_args("'')");
2180        assert_eq!(result, None);
2181    }
2182
2183    #[test]
2184    fn test_extract_path_from_decorator_args_no_string_returns_none() {
2185        let result = extract_path_from_decorator_args("some_var)");
2186        assert_eq!(result, None);
2187    }
2188
2189    #[test]
2190    fn test_extract_method_from_route_args_with_methods_keyword() {
2191        let result = extract_method_from_route_args("'/api/users', methods=['POST'])");
2192        assert_eq!(result, "POST");
2193    }
2194
2195    #[test]
2196    fn test_extract_method_from_route_args_without_methods_keyword() {
2197        let result = extract_method_from_route_args("'/api/users')");
2198        assert_eq!(result, "GET");
2199    }
2200
2201    #[test]
2202    fn test_extract_method_from_route_args_delete() {
2203        let result = extract_method_from_route_args("'/api/items', methods=['DELETE'])");
2204        assert_eq!(result, "DELETE");
2205    }
2206
2207    #[test]
2208    fn test_extract_method_from_route_args_lowercase_normalizes() {
2209        let result = extract_method_from_route_args("'/x', methods=['put'])");
2210        assert_eq!(result, "PUT");
2211    }
2212
2213    #[test]
2214    fn test_extract_first_string_literal_single_quotes() {
2215        let result = extract_first_string_literal("'POST']");
2216        assert_eq!(result, Some("POST".to_string()));
2217    }
2218
2219    #[test]
2220    fn test_extract_first_string_literal_double_quotes() {
2221        let result = extract_first_string_literal("\"DELETE\"]");
2222        assert_eq!(result, Some("DELETE".to_string()));
2223    }
2224
2225    #[test]
2226    fn test_extract_first_string_literal_empty_returns_none() {
2227        let result = extract_first_string_literal("no quotes here");
2228        assert_eq!(result, None);
2229    }
2230}