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::edge::FfiConvention;
6use sqry_core::graph::unified::node::NodeId as UnifiedNodeId;
7use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Span};
8use tree_sitter::{Node, Tree};
9
10use super::local_scopes;
11
12const DEFAULT_SCOPE_DEPTH: usize = 4;
13const STD_C_MODULES: &[&str] = &[
14 "_ctypes",
15 "_socket",
16 "_ssl",
17 "_hashlib",
18 "_json",
19 "_pickle",
20 "_struct",
21 "_sqlite3",
22 "_decimal",
23 "_lzma",
24 "_bz2",
25 "_zlib",
26 "_elementtree",
27 "_csv",
28 "_datetime",
29 "_heapq",
30 "_bisect",
31 "_random",
32 "_collections",
33 "_functools",
34 "_itertools",
35 "_operator",
36 "_io",
37 "_thread",
38 "_multiprocessing",
39 "_posixsubprocess",
40 "_asyncio",
41 "array",
42 "math",
43 "cmath",
44];
45const THIRD_PARTY_C_PACKAGES: &[&str] = &[
46 "numpy",
47 "pandas",
48 "scipy",
49 "sklearn",
50 "cv2",
51 "PIL",
52 "torch",
53 "tensorflow",
54 "lxml",
55 "psycopg2",
56 "MySQLdb",
57 "sqlite3",
58 "cryptography",
59 "bcrypt",
60 "regex",
61 "ujson",
62 "orjson",
63 "msgpack",
64 "greenlet",
65 "gevent",
66 "uvloop",
67];
68
69#[derive(Debug, Clone, Copy)]
71pub struct PythonGraphBuilder {
72 max_scope_depth: usize,
73}
74
75impl Default for PythonGraphBuilder {
76 fn default() -> Self {
77 Self {
78 max_scope_depth: DEFAULT_SCOPE_DEPTH,
79 }
80 }
81}
82
83impl PythonGraphBuilder {
84 #[must_use]
85 pub fn new(max_scope_depth: usize) -> Self {
86 Self { max_scope_depth }
87 }
88}
89
90impl GraphBuilder for PythonGraphBuilder {
91 fn build_graph(
92 &self,
93 tree: &Tree,
94 content: &[u8],
95 file: &Path,
96 staging: &mut StagingGraph,
97 ) -> GraphResult<()> {
98 let mut helper = GraphBuildHelper::new(staging, file, Language::Python);
100
101 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
103 GraphBuilderError::ParseError {
104 span: Span::default(),
105 reason: e,
106 }
107 })?;
108
109 let has_all = has_all_assignment(tree.root_node(), content);
111
112 let mut scope_tree = local_scopes::build(tree.root_node(), content)?;
114
115 let recursion_limits =
117 sqry_core::config::RecursionLimits::load_or_default().map_err(|e| {
118 GraphBuilderError::ParseError {
119 span: Span::default(),
120 reason: format!("Failed to load recursion limits: {e}"),
121 }
122 })?;
123 let file_ops_depth = recursion_limits.effective_file_ops_depth().map_err(|e| {
124 GraphBuilderError::ParseError {
125 span: Span::default(),
126 reason: format!("Invalid file_ops_depth configuration: {e}"),
127 }
128 })?;
129 let mut guard =
130 sqry_core::query::security::RecursionGuard::new(file_ops_depth).map_err(|e| {
131 GraphBuilderError::ParseError {
132 span: Span::default(),
133 reason: format!("Failed to create recursion guard: {e}"),
134 }
135 })?;
136
137 walk_tree_for_graph(
139 tree.root_node(),
140 content,
141 &ast_graph,
142 &mut helper,
143 has_all,
144 &mut guard,
145 &mut scope_tree,
146 )?;
147
148 Ok(())
149 }
150
151 fn language(&self) -> Language {
152 Language::Python
153 }
154}
155
156fn has_all_assignment(node: Node, content: &[u8]) -> bool {
158 let mut cursor = node.walk();
159 for child in node.children(&mut cursor) {
160 if child.kind() == "expression_statement" {
161 let assignment = child
163 .children(&mut child.walk())
164 .find(|c| c.kind() == "assignment" || c.kind() == "augmented_assignment");
165
166 if let Some(assignment) = assignment
167 && let Some(left) = assignment.child_by_field_name("left")
168 && let Ok(left_text) = left.utf8_text(content)
169 && left_text.trim() == "__all__"
170 {
171 return true;
172 }
173 }
174 }
175 false
176}
177
178#[allow(clippy::too_many_lines)]
183fn walk_tree_for_graph(
184 node: Node,
185 content: &[u8],
186 ast_graph: &ASTGraph,
187 helper: &mut GraphBuildHelper,
188 has_all: bool,
189 guard: &mut sqry_core::query::security::RecursionGuard,
190 scope_tree: &mut local_scopes::PythonScopeTree,
191) -> GraphResult<()> {
192 guard.enter().map_err(|e| GraphBuilderError::ParseError {
193 span: Span::default(),
194 reason: format!("Recursion limit exceeded: {e}"),
195 })?;
196
197 match node.kind() {
198 "class_definition" => {
199 if let Some(name_node) = node.child_by_field_name("name")
201 && let Ok(class_name) = name_node.utf8_text(content)
202 {
203 let span = span_from_node(node);
204
205 let qualified_name = class_name.to_string();
207
208 let class_id = helper.add_class(&qualified_name, Some(span));
210
211 process_class_inheritance(node, content, class_id, helper);
213
214 if !has_all && is_module_level(node) && is_public_name(class_name) {
218 export_from_file_module(helper, class_id);
219 }
220 }
221 }
222 "expression_statement" => {
223 process_all_assignment(node, content, helper);
225
226 process_annotated_assignment(node, content, ast_graph, helper);
228 }
229 "function_definition" => {
230 if let Some(call_context) = ast_graph.get_callable_context(node.id()) {
232 let span = span_from_node(node);
233
234 let func_name = node
236 .child_by_field_name("name")
237 .and_then(|n| n.utf8_text(content).ok())
238 .unwrap_or("");
239 let visibility = extract_visibility_from_name(func_name);
240
241 let is_property = has_property_decorator(node, content);
243
244 let return_type = extract_return_type_annotation(node, content);
246
247 let function_id = if is_property && call_context.is_method {
249 helper.add_node_with_visibility(
251 &call_context.qualified_name,
252 Some(span),
253 sqry_core::graph::unified::node::NodeKind::Property,
254 Some(visibility),
255 )
256 } else if call_context.is_method {
257 if return_type.is_some() {
259 helper.add_method_with_signature(
260 &call_context.qualified_name,
261 Some(span),
262 call_context.is_async,
263 false, Some(visibility),
265 return_type.as_deref(),
266 )
267 } else {
268 helper.add_method_with_visibility(
269 &call_context.qualified_name,
270 Some(span),
271 call_context.is_async,
272 false,
273 Some(visibility),
274 )
275 }
276 } else {
277 if return_type.is_some() {
279 helper.add_function_with_signature(
280 &call_context.qualified_name,
281 Some(span),
282 call_context.is_async,
283 false, Some(visibility),
285 return_type.as_deref(),
286 )
287 } else {
288 helper.add_function_with_visibility(
289 &call_context.qualified_name,
290 Some(span),
291 call_context.is_async,
292 false,
293 Some(visibility),
294 )
295 }
296 };
297
298 if let Some((http_method, route_path)) = extract_route_decorator_info(node, content)
300 {
301 let endpoint_name = format!("route::{http_method}::{route_path}");
302 let endpoint_id = helper.add_endpoint(&endpoint_name, Some(span));
303 helper.add_contains_edge(endpoint_id, function_id);
304 }
305
306 process_function_parameters(node, content, ast_graph, helper);
308
309 if !has_all
311 && !call_context.is_method
312 && is_module_level(node)
313 && let Some(name_node) = node.child_by_field_name("name")
314 && let Ok(func_name) = name_node.utf8_text(content)
315 && is_public_name(func_name)
316 {
317 export_from_file_module(helper, function_id);
318 }
319 }
320 }
321 "call" => {
322 let is_ffi = build_ffi_call_edge(ast_graph, node, content, helper)?;
324 if !is_ffi {
325 if let Ok(Some((caller_qname, callee_qname, argument_count, is_awaited))) =
327 build_call_for_staging(ast_graph, node, content)
328 {
329 let call_context = ast_graph.get_callable_context(node.id());
331 let is_async = call_context.is_some_and(|c| c.is_async);
332
333 let source_id = helper.ensure_function(&caller_qname, None, is_async, false);
334 let target_id = helper.ensure_function(&callee_qname, None, false, false);
335
336 let call_span = span_from_node(node);
338 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
339 helper.add_call_edge_full_with_span(
340 source_id,
341 target_id,
342 argument_count,
343 is_awaited,
344 vec![call_span],
345 );
346 }
347 }
348 }
349 "import_statement" | "import_from_statement" => {
350 if let Ok(Some((from_qname, to_qname))) =
352 build_import_for_staging(node, content, helper)
353 {
354 let from_id = helper.add_import(&from_qname, None);
356 let to_id = helper.add_import(&to_qname, Some(span_from_node(node)));
357
358 helper.add_import_edge(from_id, to_id);
360
361 if is_native_extension_import(&to_qname) {
363 build_native_import_ffi_edge(&to_qname, node, helper);
364 }
365 }
366 }
367 "identifier" => {
368 local_scopes::handle_identifier_for_reference(node, content, scope_tree, helper);
370 }
371 _ => {}
372 }
373
374 let mut cursor = node.walk();
376 for child in node.children(&mut cursor) {
377 walk_tree_for_graph(
378 child, content, ast_graph, helper, has_all, guard, scope_tree,
379 )?;
380 }
381
382 guard.exit();
383 Ok(())
384}
385
386fn build_call_for_staging(
388 ast_graph: &ASTGraph,
389 call_node: Node<'_>,
390 content: &[u8],
391) -> GraphResult<Option<(String, String, usize, bool)>> {
392 let module_context;
394 let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
395 ctx
396 } else {
397 module_context = CallContext {
399 qualified_name: "<module>".to_string(),
400 span: (0, content.len()),
401 is_async: false,
402 is_method: false,
403 class_name: None,
404 };
405 &module_context
406 };
407
408 let Some(callee_expr) = call_node.child_by_field_name("function") else {
409 return Ok(None);
410 };
411
412 let callee_text = callee_expr
413 .utf8_text(content)
414 .map_err(|_| GraphBuilderError::ParseError {
415 span: span_from_node(call_node),
416 reason: "failed to read call expression".to_string(),
417 })?
418 .trim()
419 .to_string();
420
421 if callee_text.is_empty() {
422 return Ok(None);
423 }
424
425 let callee_simple = simple_name(&callee_text);
426 if callee_simple.is_empty() {
427 return Ok(None);
428 }
429
430 let caller_qname = call_context.qualified_name();
432 let target_qname = if let Some(method_name) = callee_text.strip_prefix("self.") {
433 if let Some(class_name) = &call_context.class_name {
435 format!("{}.{}", class_name, simple_name(method_name))
436 } else {
437 callee_simple.to_string()
438 }
439 } else {
440 callee_simple.to_string()
441 };
442
443 let argument_count = count_arguments(call_node);
444 let is_awaited = is_awaited_call(call_node);
445 Ok(Some((
446 caller_qname,
447 target_qname,
448 argument_count,
449 is_awaited,
450 )))
451}
452
453fn build_import_for_staging(
455 import_node: Node<'_>,
456 content: &[u8],
457 helper: &GraphBuildHelper,
458) -> GraphResult<Option<(String, String)>> {
459 let raw_module_name = if import_node.kind() == "import_statement" {
461 import_node
462 .child_by_field_name("name")
463 .and_then(|n| extract_module_name(n, content))
464 } else if import_node.kind() == "import_from_statement" {
465 import_node
466 .child_by_field_name("module_name")
467 .and_then(|n| extract_module_name(n, content))
468 } else {
469 None
470 };
471
472 let module_name = if raw_module_name.is_none() && import_node.kind() == "import_from_statement"
474 {
475 if let Ok(import_text) = import_node.utf8_text(content) {
476 if let Some(from_idx) = import_text.find("from") {
477 if let Some(import_idx) = import_text.find("import") {
478 let between = import_text[from_idx + 4..import_idx].trim();
479 if between.starts_with('.') {
480 Some(between.to_string())
481 } else {
482 None
483 }
484 } else {
485 None
486 }
487 } else {
488 None
489 }
490 } else {
491 None
492 }
493 } else {
494 raw_module_name
495 };
496
497 let Some(module_name) = module_name else {
498 return Ok(None);
499 };
500
501 if module_name.is_empty() {
502 return Ok(None);
503 }
504
505 let resolved_path = sqry_core::graph::resolve_python_import(
507 std::path::Path::new(helper.file_path()),
508 &module_name,
509 import_node.kind() == "import_from_statement",
510 )?;
511
512 Ok(Some((helper.file_path().to_string(), resolved_path)))
514}
515
516fn span_from_node(node: Node<'_>) -> Span {
517 let start = node.start_position();
518 let end = node.end_position();
519 Span::new(
520 sqry_core::graph::node::Position::new(start.row, start.column),
521 sqry_core::graph::node::Position::new(end.row, end.column),
522 )
523}
524
525fn count_arguments(call_node: Node<'_>) -> usize {
526 call_node
527 .child_by_field_name("arguments")
528 .map_or(0, |args| {
529 args.named_children(&mut args.walk())
530 .filter(|child| {
531 !matches!(child.kind(), "," | "(" | ")")
533 })
534 .count()
535 })
536}
537
538fn is_awaited_call(call_node: Node<'_>) -> bool {
539 let mut current = call_node.parent();
540 while let Some(node) = current {
541 let kind = node.kind();
542 if kind == "await" || kind == "await_expression" {
543 return true;
544 }
545 current = node.parent();
546 }
547 false
548}
549
550fn simple_name(qualified: &str) -> &str {
555 qualified.split('.').next_back().unwrap_or(qualified)
556}
557
558fn ffi_library_simple_name(library_path: &str) -> String {
571 use std::path::Path;
572
573 let filename = Path::new(library_path)
575 .file_name()
576 .and_then(|f| f.to_str())
577 .unwrap_or(library_path);
578
579 if let Some(so_pos) = filename.find(".so.") {
581 return filename[..so_pos].to_string();
582 }
583
584 if let Some(dot_pos) = filename.find('.') {
586 let extension = &filename[dot_pos + 1..];
587
588 if extension == "so" || extension == "dll" || extension == "dylib" {
590 return filename[..dot_pos].to_string();
592 }
593 }
594
595 filename.to_string()
597}
598
599fn is_public_name(name: &str) -> bool {
605 !name.starts_with('_')
606}
607
608fn is_module_level(node: Node<'_>) -> bool {
613 let mut current = node.parent();
615 while let Some(parent) = current {
616 match parent.kind() {
617 "module" => return true,
618 "function_definition" | "class_definition" => return false,
619 _ => current = parent.parent(),
620 }
621 }
622 false
623}
624
625const FILE_MODULE_NAME: &str = "<file_module>";
630
631fn export_from_file_module(
632 helper: &mut GraphBuildHelper,
633 exported: sqry_core::graph::unified::node::NodeId,
634) {
635 let module_id = helper.add_module(FILE_MODULE_NAME, None);
636 helper.add_export_edge(module_id, exported);
637}
638
639fn extract_module_name(node: Node<'_>, content: &[u8]) -> Option<String> {
645 if node.kind() == "aliased_import" {
647 return node
649 .child_by_field_name("name")
650 .and_then(|name_node| name_node.utf8_text(content).ok())
651 .map(std::string::ToString::to_string);
652 }
653
654 node.utf8_text(content)
656 .ok()
657 .map(std::string::ToString::to_string)
658}
659
660fn process_all_assignment(node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
669 let assignment = node
671 .children(&mut node.walk())
672 .find(|child| child.kind() == "assignment" || child.kind() == "augmented_assignment");
673
674 let Some(assignment) = assignment else {
675 return;
676 };
677
678 let left = assignment.child_by_field_name("left");
680 let Some(left) = left else {
681 return;
682 };
683
684 let Ok(left_text) = left.utf8_text(content) else {
685 return;
686 };
687
688 if left_text.trim() != "__all__" {
689 return;
690 }
691
692 let right = assignment.child_by_field_name("right");
694 let Some(right) = right else {
695 return;
696 };
697
698 if right.kind() == "list" || right.kind() == "tuple" {
700 process_all_list(right, content, helper);
701 }
702}
703
704fn process_all_list(list_node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
706 for child in list_node.children(&mut list_node.walk()) {
707 if child.kind() == "string"
709 && let Some(export_name) = extract_string_content(child, content)
710 && !export_name.is_empty()
711 {
712 let span = span_from_node(child);
716 let export_id = helper.add_function(&export_name, Some(span), false, false);
717
718 export_from_file_module(helper, export_id);
720 }
721 }
722}
723
724fn extract_string_content(string_node: Node<'_>, content: &[u8]) -> Option<String> {
726 let Ok(text) = string_node.utf8_text(content) else {
729 return None;
730 };
731
732 let text = text.trim();
733
734 let stripped = text
736 .trim_start_matches(|c: char| {
737 c == 'r'
738 || c == 'b'
739 || c == 'f'
740 || c == 'u'
741 || c == 'R'
742 || c == 'B'
743 || c == 'F'
744 || c == 'U'
745 })
746 .trim_start_matches("'''")
747 .trim_end_matches("'''")
748 .trim_start_matches("\"\"\"")
749 .trim_end_matches("\"\"\"")
750 .trim_start_matches('\'')
751 .trim_end_matches('\'')
752 .trim_start_matches('"')
753 .trim_end_matches('"');
754
755 Some(stripped.to_string())
756}
757
758fn process_class_inheritance(
767 class_node: Node<'_>,
768 content: &[u8],
769 class_id: UnifiedNodeId,
770 helper: &mut GraphBuildHelper,
771) {
772 let superclasses = class_node.child_by_field_name("superclasses");
775
776 let Some(superclasses) = superclasses else {
777 return;
778 };
779
780 for child in superclasses.children(&mut superclasses.walk()) {
782 if child.kind() == "keyword_argument" {
783 continue;
785 }
786
787 match child.kind() {
788 "identifier" => {
789 if let Ok(base_name) = child.utf8_text(content) {
791 let base_name = base_name.trim();
792 if !base_name.is_empty() {
793 let span = span_from_node(child);
794 let base_id = helper.add_class(base_name, Some(span));
795 helper.add_inherits_edge(class_id, base_id);
796 }
797 }
798 }
799 "attribute" => {
800 if let Ok(base_name) = child.utf8_text(content) {
802 let base_name = base_name.trim();
803 if !base_name.is_empty() {
804 let span = span_from_node(child);
805 let base_id = helper.add_class(base_name, Some(span));
806 helper.add_inherits_edge(class_id, base_id);
807 }
808 }
809 }
810 "call" => {
811 if let Some(func) = child.child_by_field_name("function")
814 && let Ok(base_name) = func.utf8_text(content)
815 {
816 let base_name = base_name.trim();
817 if !base_name.is_empty() {
818 let span = span_from_node(child);
819 let base_id = helper.add_class(base_name, Some(span));
820 helper.add_inherits_edge(class_id, base_id);
821 }
822 }
823 }
824 "subscript" => {
825 if let Some(value) = child.child_by_field_name("value")
828 && let Ok(base_name) = value.utf8_text(content)
829 {
830 let base_name = base_name.trim();
831 if !base_name.is_empty() {
832 let span = span_from_node(child);
833 let base_id = helper.add_class(base_name, Some(span));
834 helper.add_inherits_edge(class_id, base_id);
835 }
836 }
837 }
838 _ => {}
839 }
840 }
841}
842
843#[derive(Debug, Clone)]
848struct CallContext {
849 qualified_name: String,
850 #[allow(dead_code)] span: (usize, usize),
852 is_async: bool,
853 is_method: bool,
854 class_name: Option<String>,
855}
856
857impl CallContext {
858 fn qualified_name(&self) -> String {
859 self.qualified_name.clone()
860 }
861}
862
863struct ASTGraph {
864 contexts: Vec<CallContext>,
865 node_to_context: HashMap<usize, usize>,
866}
867
868impl ASTGraph {
869 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
870 let mut contexts = Vec::new();
871 let mut node_to_context = HashMap::new();
872 let mut scope_stack: Vec<String> = Vec::new();
873 let mut class_stack: Vec<String> = Vec::new();
874
875 walk_ast(
876 tree.root_node(),
877 content,
878 &mut contexts,
879 &mut node_to_context,
880 &mut scope_stack,
881 &mut class_stack,
882 max_depth,
883 )?;
884
885 Ok(Self {
886 contexts,
887 node_to_context,
888 })
889 }
890
891 #[allow(dead_code)] fn contexts(&self) -> &[CallContext] {
893 &self.contexts
894 }
895
896 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
897 self.node_to_context
898 .get(&node_id)
899 .and_then(|idx| self.contexts.get(*idx))
900 }
901}
902
903fn walk_ast(
904 node: Node,
905 content: &[u8],
906 contexts: &mut Vec<CallContext>,
907 node_to_context: &mut HashMap<usize, usize>,
908 scope_stack: &mut Vec<String>,
909 class_stack: &mut Vec<String>,
910 max_depth: usize,
911) -> Result<(), String> {
912 if scope_stack.len() > max_depth {
913 return Ok(());
914 }
915
916 match node.kind() {
917 "class_definition" => {
918 let name_node = node
919 .child_by_field_name("name")
920 .ok_or_else(|| "class_definition missing name".to_string())?;
921 let class_name = name_node
922 .utf8_text(content)
923 .map_err(|_| "failed to read class name".to_string())?;
924
925 let qualified_class = if scope_stack.is_empty() {
927 class_name.to_string()
928 } else {
929 format!("{}.{}", scope_stack.join("."), class_name)
930 };
931
932 class_stack.push(qualified_class.clone());
933 scope_stack.push(class_name.to_string());
934
935 if let Some(body) = node.child_by_field_name("body") {
937 let mut cursor = body.walk();
938 for child in body.children(&mut cursor) {
939 walk_ast(
940 child,
941 content,
942 contexts,
943 node_to_context,
944 scope_stack,
945 class_stack,
946 max_depth,
947 )?;
948 }
949 }
950
951 class_stack.pop();
952 scope_stack.pop();
953 }
954 "function_definition" => {
955 let name_node = node
956 .child_by_field_name("name")
957 .ok_or_else(|| "function_definition missing name".to_string())?;
958 let func_name = name_node
959 .utf8_text(content)
960 .map_err(|_| "failed to read function name".to_string())?;
961
962 let is_async = node
964 .children(&mut node.walk())
965 .any(|child| child.kind() == "async");
966
967 let qualified_func = if scope_stack.is_empty() {
969 func_name.to_string()
970 } else {
971 format!("{}.{}", scope_stack.join("."), func_name)
972 };
973
974 let is_method = !class_stack.is_empty();
976 let class_name = class_stack.last().cloned();
977
978 let context_idx = contexts.len();
979 contexts.push(CallContext {
980 qualified_name: qualified_func.clone(),
981 span: (node.start_byte(), node.end_byte()),
982 is_async,
983 is_method,
984 class_name,
985 });
986
987 node_to_context.insert(node.id(), context_idx);
990
991 if let Some(body) = node.child_by_field_name("body") {
993 associate_descendants(body, context_idx, node_to_context);
994 }
995
996 scope_stack.push(func_name.to_string());
997
998 if let Some(body) = node.child_by_field_name("body") {
1000 let mut cursor = body.walk();
1001 for child in body.children(&mut cursor) {
1002 walk_ast(
1003 child,
1004 content,
1005 contexts,
1006 node_to_context,
1007 scope_stack,
1008 class_stack,
1009 max_depth,
1010 )?;
1011 }
1012 }
1013
1014 scope_stack.pop();
1015 }
1016 _ => {
1017 let mut cursor = node.walk();
1019 for child in node.children(&mut cursor) {
1020 walk_ast(
1021 child,
1022 content,
1023 contexts,
1024 node_to_context,
1025 scope_stack,
1026 class_stack,
1027 max_depth,
1028 )?;
1029 }
1030 }
1031 }
1032
1033 Ok(())
1034}
1035
1036fn associate_descendants(
1037 node: Node,
1038 context_idx: usize,
1039 node_to_context: &mut HashMap<usize, usize>,
1040) {
1041 node_to_context.insert(node.id(), context_idx);
1042
1043 let mut stack = vec![node];
1044 while let Some(current) = stack.pop() {
1045 node_to_context.insert(current.id(), context_idx);
1046
1047 let mut cursor = current.walk();
1048 for child in current.children(&mut cursor) {
1049 stack.push(child);
1050 }
1051 }
1052}
1053
1054fn build_ffi_call_edge(
1069 ast_graph: &ASTGraph,
1070 call_node: Node<'_>,
1071 content: &[u8],
1072 helper: &mut GraphBuildHelper,
1073) -> GraphResult<bool> {
1074 let Some(callee_expr) = call_node.child_by_field_name("function") else {
1075 return Ok(false);
1076 };
1077
1078 let callee_text = callee_expr
1079 .utf8_text(content)
1080 .map_err(|_| GraphBuilderError::ParseError {
1081 span: span_from_node(call_node),
1082 reason: "failed to read call expression".to_string(),
1083 })?
1084 .trim();
1085
1086 if is_ctypes_load_call(callee_text) {
1088 return Ok(build_ctypes_ffi_edge(
1089 ast_graph,
1090 call_node,
1091 content,
1092 callee_text,
1093 helper,
1094 ));
1095 }
1096
1097 if is_cffi_dlopen_call(callee_text) {
1099 return Ok(build_cffi_ffi_edge(ast_graph, call_node, content, helper));
1100 }
1101
1102 Ok(false)
1103}
1104
1105fn is_ctypes_load_call(callee_text: &str) -> bool {
1114 callee_text == "ctypes.CDLL"
1116 || callee_text == "ctypes.WinDLL"
1117 || callee_text == "ctypes.OleDLL"
1118 || callee_text == "ctypes.PyDLL"
1119 || callee_text == "ctypes.cdll.LoadLibrary"
1121 || callee_text == "ctypes.windll.LoadLibrary"
1122 || callee_text == "ctypes.oledll.LoadLibrary"
1123 || callee_text == "CDLL"
1125 || callee_text == "WinDLL"
1126 || callee_text == "OleDLL"
1127 || callee_text == "PyDLL"
1128 || callee_text == "cdll.LoadLibrary"
1130 || callee_text == "windll.LoadLibrary"
1131 || callee_text == "oledll.LoadLibrary"
1132}
1133
1134fn is_cffi_dlopen_call(callee_text: &str) -> bool {
1139 callee_text == "ffi.dlopen"
1141 || callee_text == "cffi.dlopen"
1142 || callee_text == "_ffi.dlopen"
1143 || callee_text == "FFI().dlopen"
1148}
1149
1150fn build_ctypes_ffi_edge(
1152 ast_graph: &ASTGraph,
1153 call_node: Node<'_>,
1154 content: &[u8],
1155 callee_text: &str,
1156 helper: &mut GraphBuildHelper,
1157) -> bool {
1158 let caller_id = get_ffi_caller_node_id(ast_graph, call_node, content, helper);
1160
1161 let convention = if callee_text.contains("WinDLL")
1163 || callee_text.contains("windll")
1164 || callee_text.contains("OleDLL")
1165 {
1166 FfiConvention::Stdcall
1167 } else {
1168 FfiConvention::C
1169 };
1170
1171 let library_name = extract_ffi_library_name(call_node, content)
1173 .unwrap_or_else(|| "ctypes::unknown".to_string());
1174
1175 let ffi_name = format!("native::{}", ffi_library_simple_name(&library_name));
1176 let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(call_node)));
1177
1178 helper.add_ffi_edge(caller_id, ffi_node_id, convention);
1180
1181 true
1182}
1183
1184fn build_cffi_ffi_edge(
1186 ast_graph: &ASTGraph,
1187 call_node: Node<'_>,
1188 content: &[u8],
1189 helper: &mut GraphBuildHelper,
1190) -> bool {
1191 let caller_id = get_ffi_caller_node_id(ast_graph, call_node, content, helper);
1193
1194 let library_name =
1196 extract_ffi_library_name(call_node, content).unwrap_or_else(|| "cffi::unknown".to_string());
1197
1198 let ffi_name = format!("native::{}", ffi_library_simple_name(&library_name));
1199 let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(call_node)));
1200
1201 helper.add_ffi_edge(caller_id, ffi_node_id, FfiConvention::C);
1203
1204 true
1205}
1206
1207fn get_ffi_caller_node_id(
1209 ast_graph: &ASTGraph,
1210 node: Node<'_>,
1211 content: &[u8],
1212 helper: &mut GraphBuildHelper,
1213) -> UnifiedNodeId {
1214 let module_context;
1215 let call_context = if let Some(ctx) = ast_graph.get_callable_context(node.id()) {
1216 ctx
1217 } else {
1218 module_context = CallContext {
1219 qualified_name: "<module>".to_string(),
1220 span: (0, content.len()),
1221 is_async: false,
1222 is_method: false,
1223 class_name: None,
1224 };
1225 &module_context
1226 };
1227
1228 let caller_span = Some(Span::from_bytes(call_context.span.0, call_context.span.1));
1229 helper.ensure_function(
1230 &call_context.qualified_name(),
1231 caller_span,
1232 call_context.is_async,
1233 false,
1234 )
1235}
1236
1237fn extract_ffi_library_name(call_node: Node<'_>, content: &[u8]) -> Option<String> {
1239 let args = call_node.child_by_field_name("arguments")?;
1240
1241 let mut cursor = args.walk();
1242 let first_arg = args
1243 .children(&mut cursor)
1244 .find(|child| !matches!(child.kind(), "(" | ")" | ","))?;
1245
1246 if first_arg.kind() == "string" {
1248 return extract_string_content(first_arg, content);
1249 }
1250
1251 if first_arg.kind() == "identifier" {
1253 let text = first_arg.utf8_text(content).ok()?;
1254 return Some(format!("${}", text.trim())); }
1256
1257 None
1258}
1259
1260fn is_native_extension_import(module_name: &str) -> bool {
1267 if module_name.starts_with('_') && !module_name.starts_with("__") {
1269 return true;
1270 }
1271
1272 let base_module = module_name.split('.').next().unwrap_or(module_name);
1274
1275 STD_C_MODULES.contains(&base_module) || THIRD_PARTY_C_PACKAGES.contains(&base_module)
1276}
1277
1278fn build_native_import_ffi_edge(
1280 module_name: &str,
1281 import_node: Node<'_>,
1282 helper: &mut GraphBuildHelper,
1283) {
1284 let file_path = helper.file_path().to_string();
1286 let importer_id = helper.add_module(&file_path, None);
1287
1288 let ffi_name = format!("native::{}", simple_name(module_name));
1290 let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(import_node)));
1291
1292 helper.add_ffi_edge(importer_id, ffi_node_id, FfiConvention::C);
1294}
1295
1296const ROUTE_METHOD_NAMES: &[&str] = &["get", "post", "put", "delete", "patch"];
1302
1303const ROUTE_RECEIVER_NAMES: &[&str] = &["app", "router", "blueprint"];
1307
1308fn extract_route_decorator_info(func_node: Node<'_>, content: &[u8]) -> Option<(String, String)> {
1321 let parent = func_node.parent()?;
1323 if parent.kind() != "decorated_definition" {
1324 return None;
1325 }
1326
1327 let mut cursor = parent.walk();
1329 for child in parent.children(&mut cursor) {
1330 if child.kind() != "decorator" {
1331 continue;
1332 }
1333
1334 let Ok(decorator_text) = child.utf8_text(content) else {
1335 continue;
1336 };
1337 let decorator_text = decorator_text.trim();
1338
1339 let without_at = decorator_text.strip_prefix('@')?;
1341
1342 if let Some(result) = parse_route_decorator_text(without_at) {
1344 return Some(result);
1345 }
1346 }
1347
1348 None
1349}
1350
1351fn parse_route_decorator_text(text: &str) -> Option<(String, String)> {
1359 let paren_pos = text.find('(')?;
1362 let accessor = &text[..paren_pos];
1363 let args_text = &text[paren_pos + 1..];
1364
1365 let dot_pos = accessor.rfind('.')?;
1367 let receiver = &accessor[..dot_pos];
1368 let method_name = &accessor[dot_pos + 1..];
1369
1370 let receiver_base = receiver.rsplit('.').next().unwrap_or(receiver);
1373 if !ROUTE_RECEIVER_NAMES.contains(&receiver_base) {
1374 return None;
1375 }
1376
1377 let path = extract_path_from_decorator_args(args_text)?;
1379
1380 let method_lower = method_name.to_ascii_lowercase();
1382 if ROUTE_METHOD_NAMES.contains(&method_lower.as_str()) {
1383 return Some((method_lower.to_ascii_uppercase(), path));
1385 }
1386
1387 if method_lower == "route" {
1388 let http_method = extract_method_from_route_args(args_text);
1390 return Some((http_method, path));
1391 }
1392
1393 None
1394}
1395
1396fn extract_path_from_decorator_args(args_text: &str) -> Option<String> {
1403 let trimmed = args_text.trim();
1404
1405 let (quote_char, start_pos) = {
1407 let single_pos = trimmed.find('\'');
1408 let double_pos = trimmed.find('"');
1409 match (single_pos, double_pos) {
1410 (Some(s), Some(d)) => {
1411 if s < d {
1412 ('\'', s)
1413 } else {
1414 ('"', d)
1415 }
1416 }
1417 (Some(s), None) => ('\'', s),
1418 (None, Some(d)) => ('"', d),
1419 (None, None) => return None,
1420 }
1421 };
1422
1423 let after_open = start_pos + 1;
1425 let close_pos = trimmed[after_open..].find(quote_char)?;
1426 let path = &trimmed[after_open..after_open + close_pos];
1427
1428 if path.is_empty() {
1429 return None;
1430 }
1431
1432 Some(path.to_string())
1433}
1434
1435fn extract_method_from_route_args(args_text: &str) -> String {
1440 let Some(methods_pos) = args_text.find("methods") else {
1442 return "GET".to_string();
1443 };
1444
1445 let after_methods = &args_text[methods_pos..];
1447 let Some(bracket_pos) = after_methods.find('[') else {
1448 return "GET".to_string();
1449 };
1450
1451 let after_bracket = &after_methods[bracket_pos + 1..];
1452
1453 let method_str = extract_first_string_literal(after_bracket);
1455 match method_str {
1456 Some(m) => m.to_ascii_uppercase(),
1457 None => "GET".to_string(),
1458 }
1459}
1460
1461fn extract_first_string_literal(text: &str) -> Option<String> {
1463 let trimmed = text.trim();
1464
1465 let (quote_char, start_pos) = {
1466 let single_pos = trimmed.find('\'');
1467 let double_pos = trimmed.find('"');
1468 match (single_pos, double_pos) {
1469 (Some(s), Some(d)) => {
1470 if s < d {
1471 ('\'', s)
1472 } else {
1473 ('"', d)
1474 }
1475 }
1476 (Some(s), None) => ('\'', s),
1477 (None, Some(d)) => ('"', d),
1478 (None, None) => return None,
1479 }
1480 };
1481
1482 let after_open = start_pos + 1;
1483 let close_pos = trimmed[after_open..].find(quote_char)?;
1484 let literal = &trimmed[after_open..after_open + close_pos];
1485
1486 if literal.is_empty() {
1487 return None;
1488 }
1489
1490 Some(literal.to_string())
1491}
1492
1493fn has_property_decorator(func_node: Node<'_>, content: &[u8]) -> bool {
1514 let Some(parent) = func_node.parent() else {
1516 return false;
1517 };
1518
1519 if parent.kind() != "decorated_definition" {
1521 return false;
1522 }
1523
1524 let mut cursor = parent.walk();
1526 for child in parent.children(&mut cursor) {
1527 if child.kind() == "decorator" {
1528 if let Ok(decorator_text) = child.utf8_text(content) {
1530 let decorator_text = decorator_text.trim();
1531 if decorator_text == "@property"
1533 || decorator_text.starts_with("@property(")
1534 || decorator_text.starts_with("@property (")
1535 {
1536 return true;
1537 }
1538 }
1539 }
1540 }
1541
1542 false
1543}
1544
1545fn extract_visibility_from_name(name: &str) -> &'static str {
1552 if name.starts_with("__") && !name.ends_with("__") {
1553 "private"
1554 } else if name.starts_with('_') {
1555 "protected"
1556 } else {
1557 "public"
1558 }
1559}
1560
1561fn find_containing_scope(node: Node<'_>, content: &[u8], ast_graph: &ASTGraph) -> String {
1573 let mut current = node;
1574 let mut found_class_name: Option<String> = None;
1575
1576 while let Some(parent) = current.parent() {
1578 match parent.kind() {
1579 "function_definition" => {
1580 if let Some(ctx) = ast_graph.get_callable_context(parent.id()) {
1582 return ctx.qualified_name.clone();
1583 }
1584 }
1585 "class_definition" => {
1586 if found_class_name.is_none() {
1589 if let Some(name_node) = parent.child_by_field_name("name")
1591 && let Ok(class_name) = name_node.utf8_text(content)
1592 {
1593 found_class_name = Some(class_name.to_string());
1594 }
1595 }
1596 }
1597 _ => {}
1598 }
1599 current = parent;
1600 }
1601
1602 found_class_name.unwrap_or_default()
1604}
1605
1606fn extract_return_type_annotation(func_node: Node<'_>, content: &[u8]) -> Option<String> {
1613 let return_type_node = func_node.child_by_field_name("return_type")?;
1614 extract_type_from_node(return_type_node, content)
1615}
1616
1617fn process_function_parameters(
1624 func_node: Node<'_>,
1625 content: &[u8],
1626 ast_graph: &ASTGraph,
1627 helper: &mut GraphBuildHelper,
1628) {
1629 let Some(params_node) = func_node.child_by_field_name("parameters") else {
1630 return;
1631 };
1632
1633 let scope_prefix = ast_graph
1635 .get_callable_context(func_node.id())
1636 .map_or("", |ctx| ctx.qualified_name.as_str());
1637
1638 for param in params_node.children(&mut params_node.walk()) {
1640 match param.kind() {
1643 "typed_parameter" | "typed_default_parameter" => {
1644 process_typed_parameter(param, content, scope_prefix, helper);
1645 }
1646 "identifier" | "default_parameter" => {}
1650 _ => {
1651 if param.child_by_field_name("type").is_some() {
1654 process_typed_parameter(param, content, scope_prefix, helper);
1655 }
1656 }
1657 }
1658 }
1659}
1660
1661fn process_typed_parameter(
1666 param: Node<'_>,
1667 content: &[u8],
1668 scope_prefix: &str,
1669 helper: &mut GraphBuildHelper,
1670) {
1671 let param_name = if let Some(name_node) = param.child_by_field_name("name") {
1673 name_node.utf8_text(content).ok()
1674 } else {
1675 param
1677 .children(&mut param.walk())
1678 .find(|c| c.kind() == "identifier")
1679 .and_then(|n| n.utf8_text(content).ok())
1680 };
1681
1682 let Some(param_name) = param_name else {
1683 return;
1684 };
1685
1686 if param_name == "self" || param_name == "cls" {
1688 return;
1689 }
1690
1691 let Some(type_node) = param.child_by_field_name("type") else {
1693 return;
1694 };
1695
1696 let Some(type_name) = extract_type_from_node(type_node, content) else {
1697 return;
1698 };
1699
1700 let qualified_param_name = if scope_prefix.is_empty() {
1703 format!(":{param_name}")
1705 } else {
1706 format!("{scope_prefix}:{param_name}")
1707 };
1708
1709 let param_id = helper.add_variable(&qualified_param_name, Some(span_from_node(param)));
1711
1712 let type_id = helper.add_type(&type_name, None);
1714
1715 helper.add_typeof_edge(param_id, type_id);
1717 helper.add_reference_edge(param_id, type_id);
1718}
1719
1720fn process_annotated_assignment(
1727 expr_stmt_node: Node<'_>,
1728 content: &[u8],
1729 ast_graph: &ASTGraph,
1730 helper: &mut GraphBuildHelper,
1731) {
1732 let scope_prefix = find_containing_scope(expr_stmt_node, content, ast_graph);
1735
1736 for child in expr_stmt_node.children(&mut expr_stmt_node.walk()) {
1738 if child.kind() == "assignment" {
1739 process_typed_assignment(child, content, &scope_prefix, helper);
1740 }
1741 }
1742}
1743
1744fn process_typed_assignment(
1748 assignment_node: Node<'_>,
1749 content: &[u8],
1750 scope_prefix: &str,
1751 helper: &mut GraphBuildHelper,
1752) {
1753 let Some(left) = assignment_node.child_by_field_name("left") else {
1758 return;
1759 };
1760
1761 let Some(type_node) = assignment_node.child_by_field_name("type") else {
1762 return;
1763 };
1764
1765 let Ok(var_name) = left.utf8_text(content) else {
1767 return;
1768 };
1769
1770 let Some(type_name) = extract_type_from_node(type_node, content) else {
1772 return;
1773 };
1774
1775 let qualified_var_name = if scope_prefix.is_empty() {
1779 var_name.to_string()
1781 } else if scope_prefix.contains('.') && !scope_prefix.contains(':') {
1782 format!("{scope_prefix}.{var_name}")
1784 } else {
1785 format!("{scope_prefix}:{var_name}")
1787 };
1788
1789 let var_id = helper.add_variable(&qualified_var_name, Some(span_from_node(assignment_node)));
1791
1792 let type_id = helper.add_type(&type_name, None);
1794
1795 helper.add_typeof_edge(var_id, type_id);
1797 helper.add_reference_edge(var_id, type_id);
1798}
1799
1800fn extract_type_from_node(type_node: Node<'_>, content: &[u8]) -> Option<String> {
1810 match type_node.kind() {
1811 "type" => {
1812 type_node
1814 .named_child(0)
1815 .and_then(|child| extract_type_from_node(child, content))
1816 }
1817 "identifier" => {
1818 type_node.utf8_text(content).ok().map(String::from)
1820 }
1821 "string" => {
1822 let text = type_node.utf8_text(content).ok()?;
1825 let trimmed = text.trim();
1826
1827 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
1829 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
1830 {
1831 let unquoted = &trimmed[1..trimmed.len() - 1];
1832 Some(normalize_union_type(unquoted))
1834 } else {
1835 Some(trimmed.to_string())
1836 }
1837 }
1838 "binary_operator" => {
1839 if let Some(left) = type_node.child_by_field_name("left") {
1842 extract_type_from_node(left, content)
1843 } else {
1844 type_node
1846 .utf8_text(content)
1847 .ok()
1848 .map(|text| normalize_union_type(text.trim()))
1849 }
1850 }
1851 "generic_type" | "subscript" => {
1852 if let Some(value_node) = type_node.child_by_field_name("value") {
1856 extract_type_from_node(value_node, content)
1857 } else {
1858 type_node
1860 .named_child(0)
1861 .and_then(|child| extract_type_from_node(child, content))
1862 .or_else(|| {
1863 type_node.utf8_text(content).ok().and_then(|text| {
1865 text.split('[').next().map(|s| s.trim().to_string())
1867 })
1868 })
1869 }
1870 }
1871 "attribute" => {
1872 type_node.utf8_text(content).ok().map(String::from)
1874 }
1875 "list" | "tuple" | "set" => {
1876 type_node.utf8_text(content).ok().map(String::from)
1878 }
1879 _ => {
1880 let text = type_node.utf8_text(content).ok()?;
1883 let trimmed = text.trim();
1884
1885 if trimmed.contains('[') {
1887 trimmed.split('[').next().map(|s| s.trim().to_string())
1888 } else {
1889 Some(normalize_union_type(trimmed))
1891 }
1892 }
1893 }
1894}
1895
1896fn normalize_union_type(type_str: &str) -> String {
1903 if let Some(pipe_pos) = type_str.find('|') {
1904 type_str[..pipe_pos].trim().to_string()
1906 } else {
1907 type_str.to_string()
1908 }
1909}
1910
1911#[cfg(test)]
1912mod tests {
1913 use super::*;
1914
1915 #[test]
1916 fn test_simple_name_extracts_dotted_identifiers() {
1917 assert_eq!(simple_name("module.func"), "func");
1919 assert_eq!(simple_name("obj.method"), "method");
1920 assert_eq!(simple_name("package.module.func"), "func");
1921 assert_eq!(simple_name("self.helper"), "helper");
1922
1923 assert_eq!(simple_name("function"), "function");
1925 assert_eq!(simple_name(""), "");
1926 }
1927
1928 #[test]
1929 fn test_ffi_library_simple_name_extracts_library_base_names() {
1930 assert_eq!(ffi_library_simple_name("libfoo.so"), "libfoo");
1932 assert_eq!(ffi_library_simple_name("lib1.so"), "lib1");
1933 assert_eq!(ffi_library_simple_name("lib2.so"), "lib2");
1934
1935 assert_eq!(ffi_library_simple_name("kernel32.dll"), "kernel32");
1937 assert_eq!(ffi_library_simple_name("libSystem.dylib"), "libSystem");
1938
1939 assert_eq!(ffi_library_simple_name("libc.so.6"), "libc");
1941
1942 assert_eq!(ffi_library_simple_name("kernel32"), "kernel32");
1944 assert_eq!(ffi_library_simple_name("numpy"), "numpy");
1945
1946 assert_eq!(ffi_library_simple_name("$libname"), "$libname");
1948
1949 assert_eq!(ffi_library_simple_name(""), "");
1951 assert_eq!(ffi_library_simple_name("lib.so"), "lib");
1952 }
1953
1954 #[test]
1955 fn test_ffi_library_simple_name_prevents_duplicate_edges() {
1956 let name1 = ffi_library_simple_name("lib1.so");
1958 let name2 = ffi_library_simple_name("lib2.so");
1959
1960 assert_ne!(
1962 name1, name2,
1963 "lib1.so and lib2.so must produce different simple names"
1964 );
1965 assert_eq!(name1, "lib1");
1966 assert_eq!(name2, "lib2");
1967 }
1968
1969 #[test]
1970 fn test_ffi_library_simple_name_handles_directory_paths() {
1971 assert_eq!(ffi_library_simple_name("/opt/v1.2/libfoo.so"), "libfoo");
1973 assert_eq!(
1974 ffi_library_simple_name("/usr/lib/x86_64-linux-gnu/libc.so.6"),
1975 "libc"
1976 );
1977 assert_eq!(ffi_library_simple_name("libs/lib1.so"), "lib1");
1978
1979 assert_eq!(ffi_library_simple_name("./libs/kernel32.dll"), "kernel32");
1981 assert_eq!(
1982 ffi_library_simple_name("../lib/libSystem.dylib"),
1983 "libSystem"
1984 );
1985 }
1986
1987 #[test]
1992 fn test_parse_route_decorator_app_route_default_get() {
1993 let result = parse_route_decorator_text("app.route('/api/users')");
1994 assert_eq!(result, Some(("GET".to_string(), "/api/users".to_string())));
1995 }
1996
1997 #[test]
1998 fn test_parse_route_decorator_app_route_with_methods_post() {
1999 let result = parse_route_decorator_text("app.route('/api/users', methods=['POST'])");
2000 assert_eq!(result, Some(("POST".to_string(), "/api/users".to_string())));
2001 }
2002
2003 #[test]
2004 fn test_parse_route_decorator_app_route_with_methods_put_double_quotes() {
2005 let result = parse_route_decorator_text("app.route(\"/api/items\", methods=[\"PUT\"])");
2006 assert_eq!(result, Some(("PUT".to_string(), "/api/items".to_string())));
2007 }
2008
2009 #[test]
2010 fn test_parse_route_decorator_app_get() {
2011 let result = parse_route_decorator_text("app.get('/api/users')");
2012 assert_eq!(result, Some(("GET".to_string(), "/api/users".to_string())));
2013 }
2014
2015 #[test]
2016 fn test_parse_route_decorator_app_post() {
2017 let result = parse_route_decorator_text("app.post('/api/items')");
2018 assert_eq!(result, Some(("POST".to_string(), "/api/items".to_string())));
2019 }
2020
2021 #[test]
2022 fn test_parse_route_decorator_app_put() {
2023 let result = parse_route_decorator_text("app.put('/api/items/1')");
2024 assert_eq!(
2025 result,
2026 Some(("PUT".to_string(), "/api/items/1".to_string()))
2027 );
2028 }
2029
2030 #[test]
2031 fn test_parse_route_decorator_app_delete() {
2032 let result = parse_route_decorator_text("app.delete('/api/items/1')");
2033 assert_eq!(
2034 result,
2035 Some(("DELETE".to_string(), "/api/items/1".to_string()))
2036 );
2037 }
2038
2039 #[test]
2040 fn test_parse_route_decorator_app_patch() {
2041 let result = parse_route_decorator_text("app.patch('/api/items/1')");
2042 assert_eq!(
2043 result,
2044 Some(("PATCH".to_string(), "/api/items/1".to_string()))
2045 );
2046 }
2047
2048 #[test]
2049 fn test_parse_route_decorator_router_get_fastapi() {
2050 let result = parse_route_decorator_text("router.get('/api/users')");
2051 assert_eq!(result, Some(("GET".to_string(), "/api/users".to_string())));
2052 }
2053
2054 #[test]
2055 fn test_parse_route_decorator_router_post_fastapi() {
2056 let result = parse_route_decorator_text("router.post('/api/items')");
2057 assert_eq!(result, Some(("POST".to_string(), "/api/items".to_string())));
2058 }
2059
2060 #[test]
2061 fn test_parse_route_decorator_blueprint_route() {
2062 let result = parse_route_decorator_text("blueprint.route('/health')");
2063 assert_eq!(result, Some(("GET".to_string(), "/health".to_string())));
2064 }
2065
2066 #[test]
2067 fn test_parse_route_decorator_unknown_receiver_returns_none() {
2068 let result = parse_route_decorator_text("server.get('/api/users')");
2070 assert_eq!(result, None);
2071 }
2072
2073 #[test]
2074 fn test_parse_route_decorator_unknown_method_returns_none() {
2075 let result = parse_route_decorator_text("app.options('/api/users')");
2077 assert_eq!(result, None);
2078 }
2079
2080 #[test]
2081 fn test_parse_route_decorator_no_parens_returns_none() {
2082 let result = parse_route_decorator_text("app.route");
2083 assert_eq!(result, None);
2084 }
2085
2086 #[test]
2087 fn test_parse_route_decorator_no_dot_returns_none() {
2088 let result = parse_route_decorator_text("route('/api/users')");
2089 assert_eq!(result, None);
2090 }
2091
2092 #[test]
2093 fn test_extract_path_from_decorator_args_single_quotes() {
2094 let result = extract_path_from_decorator_args("'/api/users')");
2095 assert_eq!(result, Some("/api/users".to_string()));
2096 }
2097
2098 #[test]
2099 fn test_extract_path_from_decorator_args_double_quotes() {
2100 let result = extract_path_from_decorator_args("\"/api/items\")");
2101 assert_eq!(result, Some("/api/items".to_string()));
2102 }
2103
2104 #[test]
2105 fn test_extract_path_from_decorator_args_empty_returns_none() {
2106 let result = extract_path_from_decorator_args("'')");
2107 assert_eq!(result, None);
2108 }
2109
2110 #[test]
2111 fn test_extract_path_from_decorator_args_no_string_returns_none() {
2112 let result = extract_path_from_decorator_args("some_var)");
2113 assert_eq!(result, None);
2114 }
2115
2116 #[test]
2117 fn test_extract_method_from_route_args_with_methods_keyword() {
2118 let result = extract_method_from_route_args("'/api/users', methods=['POST'])");
2119 assert_eq!(result, "POST");
2120 }
2121
2122 #[test]
2123 fn test_extract_method_from_route_args_without_methods_keyword() {
2124 let result = extract_method_from_route_args("'/api/users')");
2125 assert_eq!(result, "GET");
2126 }
2127
2128 #[test]
2129 fn test_extract_method_from_route_args_delete() {
2130 let result = extract_method_from_route_args("'/api/items', methods=['DELETE'])");
2131 assert_eq!(result, "DELETE");
2132 }
2133
2134 #[test]
2135 fn test_extract_method_from_route_args_lowercase_normalizes() {
2136 let result = extract_method_from_route_args("'/x', methods=['put'])");
2137 assert_eq!(result, "PUT");
2138 }
2139
2140 #[test]
2141 fn test_extract_first_string_literal_single_quotes() {
2142 let result = extract_first_string_literal("'POST']");
2143 assert_eq!(result, Some("POST".to_string()));
2144 }
2145
2146 #[test]
2147 fn test_extract_first_string_literal_double_quotes() {
2148 let result = extract_first_string_literal("\"DELETE\"]");
2149 assert_eq!(result, Some("DELETE".to_string()));
2150 }
2151
2152 #[test]
2153 fn test_extract_first_string_literal_empty_returns_none() {
2154 let result = extract_first_string_literal("no quotes here");
2155 assert_eq!(result, None);
2156 }
2157}