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