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#[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 let mut helper = GraphBuildHelper::new(staging, file, Language::Python);
102
103 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 let has_all = has_all_assignment(tree.root_node(), content);
113
114 let mut scope_tree = local_scopes::build(tree.root_node(), content)?;
116
117 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_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
158fn 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 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#[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 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 let qualified_name = class_name.to_string();
209
210 let class_id = helper.add_class(&qualified_name, Some(span));
212
213 process_class_inheritance(node, content, class_id, helper);
215
216 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 process_all_assignment(node, content, helper);
227
228 process_annotated_assignment(node, content, ast_graph, helper);
230 }
231 "function_definition" => {
232 if let Some(call_context) = ast_graph.get_callable_context(node.id()) {
234 let span = span_from_node(node);
235
236 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 let is_property = has_property_decorator(node, content);
245
246 let return_type = extract_return_type_annotation(node, content);
249
250 let return_type_source = extract_return_type_source_text(node, content);
256
257 let function_id = if is_property && call_context.is_method {
259 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 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, 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 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, 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 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 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_function_parameters(node, content, ast_graph, helper);
351
352 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 let is_ffi = build_ffi_call_edge(ast_graph, node, content, helper)?;
367 if !is_ffi {
368 if let Ok(Some((caller_qname, callee_qname, argument_count, is_awaited))) =
370 build_call_for_staging(ast_graph, node, content)
371 {
372 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 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 if let Ok(Some((from_qname, to_qname))) =
397 build_import_for_staging(node, content, helper)
398 {
399 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 helper.add_import_edge(from_id, to_id);
405
406 if is_native_extension_import(&to_qname) {
408 build_native_import_ffi_edge(&to_qname, node, helper);
409 }
410 }
411 }
412 "identifier" => {
413 local_scopes::handle_identifier_for_reference(node, content, scope_tree, helper);
415 }
416 _ => {}
417 }
418
419 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
431fn build_call_for_staging(
433 ast_graph: &ASTGraph,
434 call_node: Node<'_>,
435 content: &[u8],
436) -> GraphResult<Option<(String, String, usize, bool)>> {
437 let module_context;
439 let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
440 ctx
441 } else {
442 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 let caller_qname = call_context.qualified_name();
477 let target_qname = if let Some(method_name) = callee_text.strip_prefix("self.") {
478 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
498fn build_import_for_staging(
500 import_node: Node<'_>,
501 content: &[u8],
502 helper: &GraphBuildHelper,
503) -> GraphResult<Option<(String, String)>> {
504 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 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 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 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 !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
595fn simple_name(qualified: &str) -> &str {
600 qualified.split('.').next_back().unwrap_or(qualified)
601}
602
603fn ffi_library_simple_name(library_path: &str) -> String {
616 use std::path::Path;
617
618 let filename = Path::new(library_path)
620 .file_name()
621 .and_then(|f| f.to_str())
622 .unwrap_or(library_path);
623
624 if let Some(so_pos) = filename.find(".so.") {
626 return filename[..so_pos].to_string();
627 }
628
629 if let Some(dot_pos) = filename.find('.') {
631 let extension = &filename[dot_pos + 1..];
632
633 if extension == "so" || extension == "dll" || extension == "dylib" {
635 return filename[..dot_pos].to_string();
637 }
638 }
639
640 filename.to_string()
642}
643
644fn is_public_name(name: &str) -> bool {
650 !name.starts_with('_')
651}
652
653fn is_module_level(node: Node<'_>) -> bool {
658 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
670const 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
684fn extract_module_name(node: Node<'_>, content: &[u8]) -> Option<String> {
690 if node.kind() == "aliased_import" {
692 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 node.utf8_text(content)
701 .ok()
702 .map(std::string::ToString::to_string)
703}
704
705fn process_all_assignment(node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
714 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 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 let right = assignment.child_by_field_name("right");
739 let Some(right) = right else {
740 return;
741 };
742
743 if right.kind() == "list" || right.kind() == "tuple" {
745 process_all_list(right, content, helper);
746 }
747}
748
749fn process_all_list(list_node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
751 for child in list_node.children(&mut list_node.walk()) {
752 if child.kind() == "string"
754 && let Some(export_name) = extract_string_content(child, content)
755 && !export_name.is_empty()
756 {
757 let span = span_from_node(child);
761 let export_id = helper.add_function(&export_name, Some(span), false, false);
762
763 export_from_file_module(helper, export_id);
765 }
766 }
767}
768
769fn extract_string_content(string_node: Node<'_>, content: &[u8]) -> Option<String> {
771 let Ok(text) = string_node.utf8_text(content) else {
774 return None;
775 };
776
777 let text = text.trim();
778
779 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
803fn process_class_inheritance(
812 class_node: Node<'_>,
813 content: &[u8],
814 class_id: UnifiedNodeId,
815 helper: &mut GraphBuildHelper,
816) {
817 let superclasses = class_node.child_by_field_name("superclasses");
820
821 let Some(superclasses) = superclasses else {
822 return;
823 };
824
825 for child in superclasses.children(&mut superclasses.walk()) {
827 if child.kind() == "keyword_argument" {
828 continue;
830 }
831
832 match child.kind() {
833 "identifier" => {
834 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 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 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 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#[derive(Debug, Clone)]
893struct CallContext {
894 qualified_name: String,
895 #[allow(dead_code)] 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)] 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 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 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 let is_async = node
1009 .children(&mut node.walk())
1010 .any(|child| child.kind() == "async");
1011
1012 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 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 node_to_context.insert(node.id(), context_idx);
1035
1036 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 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 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
1099fn 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 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 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
1150fn is_ctypes_load_call(callee_text: &str) -> bool {
1159 callee_text == "ctypes.CDLL"
1161 || callee_text == "ctypes.WinDLL"
1162 || callee_text == "ctypes.OleDLL"
1163 || callee_text == "ctypes.PyDLL"
1164 || callee_text == "ctypes.cdll.LoadLibrary"
1166 || callee_text == "ctypes.windll.LoadLibrary"
1167 || callee_text == "ctypes.oledll.LoadLibrary"
1168 || callee_text == "CDLL"
1170 || callee_text == "WinDLL"
1171 || callee_text == "OleDLL"
1172 || callee_text == "PyDLL"
1173 || callee_text == "cdll.LoadLibrary"
1175 || callee_text == "windll.LoadLibrary"
1176 || callee_text == "oledll.LoadLibrary"
1177}
1178
1179fn is_cffi_dlopen_call(callee_text: &str) -> bool {
1184 callee_text == "ffi.dlopen"
1186 || callee_text == "cffi.dlopen"
1187 || callee_text == "_ffi.dlopen"
1188 || callee_text == "FFI().dlopen"
1193}
1194
1195fn 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 let caller_id = get_ffi_caller_node_id(ast_graph, call_node, content, helper);
1205
1206 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 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 helper.add_ffi_edge(caller_id, ffi_node_id, convention);
1225
1226 true
1227}
1228
1229fn build_cffi_ffi_edge(
1231 ast_graph: &ASTGraph,
1232 call_node: Node<'_>,
1233 content: &[u8],
1234 helper: &mut GraphBuildHelper,
1235) -> bool {
1236 let caller_id = get_ffi_caller_node_id(ast_graph, call_node, content, helper);
1238
1239 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 helper.add_ffi_edge(caller_id, ffi_node_id, FfiConvention::C);
1248
1249 true
1250}
1251
1252fn 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
1282fn 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 if first_arg.kind() == "string" {
1293 return extract_string_content(first_arg, content);
1294 }
1295
1296 if first_arg.kind() == "identifier" {
1298 let text = first_arg.utf8_text(content).ok()?;
1299 return Some(format!("${}", text.trim())); }
1301
1302 None
1303}
1304
1305fn is_native_extension_import(module_name: &str) -> bool {
1312 if module_name.starts_with('_') && !module_name.starts_with("__") {
1314 return true;
1315 }
1316
1317 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
1323fn build_native_import_ffi_edge(
1325 module_name: &str,
1326 import_node: Node<'_>,
1327 helper: &mut GraphBuildHelper,
1328) {
1329 let file_path = helper.file_path().to_string();
1331 let importer_id = helper.add_module(&file_path, None);
1332
1333 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 helper.add_ffi_edge(importer_id, ffi_node_id, FfiConvention::C);
1339}
1340
1341const ROUTE_METHOD_NAMES: &[&str] = &["get", "post", "put", "delete", "patch"];
1347
1348const ROUTE_RECEIVER_NAMES: &[&str] = &["app", "router", "blueprint"];
1352
1353fn extract_route_decorator_info(func_node: Node<'_>, content: &[u8]) -> Option<(String, String)> {
1366 let parent = func_node.parent()?;
1368 if parent.kind() != "decorated_definition" {
1369 return None;
1370 }
1371
1372 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 let without_at = decorator_text.strip_prefix('@')?;
1386
1387 if let Some(result) = parse_route_decorator_text(without_at) {
1389 return Some(result);
1390 }
1391 }
1392
1393 None
1394}
1395
1396fn parse_route_decorator_text(text: &str) -> Option<(String, String)> {
1404 let paren_pos = text.find('(')?;
1407 let accessor = &text[..paren_pos];
1408 let args_text = &text[paren_pos + 1..];
1409
1410 let dot_pos = accessor.rfind('.')?;
1412 let receiver = &accessor[..dot_pos];
1413 let method_name = &accessor[dot_pos + 1..];
1414
1415 let receiver_base = receiver.rsplit('.').next().unwrap_or(receiver);
1418 if !ROUTE_RECEIVER_NAMES.contains(&receiver_base) {
1419 return None;
1420 }
1421
1422 let path = extract_path_from_decorator_args(args_text)?;
1424
1425 let method_lower = method_name.to_ascii_lowercase();
1427 if ROUTE_METHOD_NAMES.contains(&method_lower.as_str()) {
1428 return Some((method_lower.to_ascii_uppercase(), path));
1430 }
1431
1432 if method_lower == "route" {
1433 let http_method = extract_method_from_route_args(args_text);
1435 return Some((http_method, path));
1436 }
1437
1438 None
1439}
1440
1441fn extract_path_from_decorator_args(args_text: &str) -> Option<String> {
1448 let trimmed = args_text.trim();
1449
1450 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 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
1480fn extract_method_from_route_args(args_text: &str) -> String {
1485 let Some(methods_pos) = args_text.find("methods") else {
1487 return "GET".to_string();
1488 };
1489
1490 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 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
1506fn 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
1538fn has_property_decorator(func_node: Node<'_>, content: &[u8]) -> bool {
1559 let Some(parent) = func_node.parent() else {
1561 return false;
1562 };
1563
1564 if parent.kind() != "decorated_definition" {
1566 return false;
1567 }
1568
1569 let mut cursor = parent.walk();
1571 for child in parent.children(&mut cursor) {
1572 if child.kind() == "decorator" {
1573 if let Ok(decorator_text) = child.utf8_text(content) {
1575 let decorator_text = decorator_text.trim();
1576 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
1590fn 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
1606fn 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 while let Some(parent) = current.parent() {
1623 match parent.kind() {
1624 "function_definition" => {
1625 if let Some(ctx) = ast_graph.get_callable_context(parent.id()) {
1627 return ctx.qualified_name.clone();
1628 }
1629 }
1630 "class_definition" => {
1631 if found_class_name.is_none() {
1634 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 found_class_name.unwrap_or_default()
1649}
1650
1651fn 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
1662fn 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
1690fn 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 let scope_prefix = ast_graph
1708 .get_callable_context(func_node.id())
1709 .map_or("", |ctx| ctx.qualified_name.as_str());
1710
1711 for param in params_node.children(&mut params_node.walk()) {
1713 match param.kind() {
1716 "typed_parameter" | "typed_default_parameter" => {
1717 process_typed_parameter(param, content, scope_prefix, helper);
1718 }
1719 "identifier" | "default_parameter" => {}
1723 _ => {
1724 if param.child_by_field_name("type").is_some() {
1727 process_typed_parameter(param, content, scope_prefix, helper);
1728 }
1729 }
1730 }
1731 }
1732}
1733
1734fn process_typed_parameter(
1739 param: Node<'_>,
1740 content: &[u8],
1741 scope_prefix: &str,
1742 helper: &mut GraphBuildHelper,
1743) {
1744 let param_name = if let Some(name_node) = param.child_by_field_name("name") {
1746 name_node.utf8_text(content).ok()
1747 } else {
1748 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 if param_name == "self" || param_name == "cls" {
1761 return;
1762 }
1763
1764 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 let qualified_param_name = if scope_prefix.is_empty() {
1776 format!(":{param_name}")
1778 } else {
1779 format!("{scope_prefix}:{param_name}")
1780 };
1781
1782 let param_id = helper.add_variable(&qualified_param_name, Some(span_from_node(param)));
1784
1785 let type_id = helper.add_type(&type_name, None);
1787
1788 helper.add_typeof_edge(param_id, type_id);
1790 helper.add_reference_edge(param_id, type_id);
1791}
1792
1793fn process_annotated_assignment(
1800 expr_stmt_node: Node<'_>,
1801 content: &[u8],
1802 ast_graph: &ASTGraph,
1803 helper: &mut GraphBuildHelper,
1804) {
1805 let scope_prefix = find_containing_scope(expr_stmt_node, content, ast_graph);
1808
1809 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
1817fn process_typed_assignment(
1821 assignment_node: Node<'_>,
1822 content: &[u8],
1823 scope_prefix: &str,
1824 helper: &mut GraphBuildHelper,
1825) {
1826 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 let Ok(var_name) = left.utf8_text(content) else {
1840 return;
1841 };
1842
1843 let Some(type_name) = extract_type_from_node(type_node, content) else {
1845 return;
1846 };
1847
1848 let qualified_var_name = if scope_prefix.is_empty() {
1852 var_name.to_string()
1854 } else if scope_prefix.contains('.') && !scope_prefix.contains(':') {
1855 format!("{scope_prefix}.{var_name}")
1857 } else {
1858 format!("{scope_prefix}:{var_name}")
1860 };
1861
1862 let var_id = helper.add_variable(&qualified_var_name, Some(span_from_node(assignment_node)));
1864
1865 let type_id = helper.add_type(&type_name, None);
1867
1868 helper.add_typeof_edge(var_id, type_id);
1870 helper.add_reference_edge(var_id, type_id);
1871}
1872
1873fn extract_type_from_node(type_node: Node<'_>, content: &[u8]) -> Option<String> {
1883 match type_node.kind() {
1884 "type" => {
1885 type_node
1887 .named_child(0)
1888 .and_then(|child| extract_type_from_node(child, content))
1889 }
1890 "identifier" => {
1891 type_node.utf8_text(content).ok().map(String::from)
1893 }
1894 "string" => {
1895 let text = type_node.utf8_text(content).ok()?;
1898 let trimmed = text.trim();
1899
1900 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
1902 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
1903 {
1904 let unquoted = &trimmed[1..trimmed.len() - 1];
1905 Some(normalize_union_type(unquoted))
1907 } else {
1908 Some(trimmed.to_string())
1909 }
1910 }
1911 "binary_operator" => {
1912 if let Some(left) = type_node.child_by_field_name("left") {
1915 extract_type_from_node(left, content)
1916 } else {
1917 type_node
1919 .utf8_text(content)
1920 .ok()
1921 .map(|text| normalize_union_type(text.trim()))
1922 }
1923 }
1924 "generic_type" | "subscript" => {
1925 if let Some(value_node) = type_node.child_by_field_name("value") {
1929 extract_type_from_node(value_node, content)
1930 } else {
1931 type_node
1933 .named_child(0)
1934 .and_then(|child| extract_type_from_node(child, content))
1935 .or_else(|| {
1936 type_node.utf8_text(content).ok().and_then(|text| {
1938 text.split('[').next().map(|s| s.trim().to_string())
1940 })
1941 })
1942 }
1943 }
1944 "attribute" => {
1945 type_node.utf8_text(content).ok().map(String::from)
1947 }
1948 "list" | "tuple" | "set" => {
1949 type_node.utf8_text(content).ok().map(String::from)
1951 }
1952 _ => {
1953 let text = type_node.utf8_text(content).ok()?;
1956 let trimmed = text.trim();
1957
1958 if trimmed.contains('[') {
1960 trimmed.split('[').next().map(|s| s.trim().to_string())
1961 } else {
1962 Some(normalize_union_type(trimmed))
1964 }
1965 }
1966 }
1967}
1968
1969fn normalize_union_type(type_str: &str) -> String {
1976 if let Some(pipe_pos) = type_str.find('|') {
1977 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 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 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 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 assert_eq!(ffi_library_simple_name("kernel32.dll"), "kernel32");
2010 assert_eq!(ffi_library_simple_name("libSystem.dylib"), "libSystem");
2011
2012 assert_eq!(ffi_library_simple_name("libc.so.6"), "libc");
2014
2015 assert_eq!(ffi_library_simple_name("kernel32"), "kernel32");
2017 assert_eq!(ffi_library_simple_name("numpy"), "numpy");
2018
2019 assert_eq!(ffi_library_simple_name("$libname"), "$libname");
2021
2022 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 let name1 = ffi_library_simple_name("lib1.so");
2031 let name2 = ffi_library_simple_name("lib2.so");
2032
2033 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 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 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 #[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 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 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}