1use std::{collections::HashMap, path::Path};
2
3use sqry_core::graph::unified::StagingGraph;
4use sqry_core::graph::unified::build::GraphBuildHelper;
5use sqry_core::graph::unified::build::helper::CalleeKindHint;
6use sqry_core::graph::unified::edge::FfiConvention;
7use sqry_core::graph::unified::node::NodeId as UnifiedNodeId;
8use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Span};
9use tree_sitter::{Node, Tree};
10
11use super::local_scopes;
12
13const DEFAULT_SCOPE_DEPTH: usize = 4;
14const STD_C_MODULES: &[&str] = &[
15 "_ctypes",
16 "_socket",
17 "_ssl",
18 "_hashlib",
19 "_json",
20 "_pickle",
21 "_struct",
22 "_sqlite3",
23 "_decimal",
24 "_lzma",
25 "_bz2",
26 "_zlib",
27 "_elementtree",
28 "_csv",
29 "_datetime",
30 "_heapq",
31 "_bisect",
32 "_random",
33 "_collections",
34 "_functools",
35 "_itertools",
36 "_operator",
37 "_io",
38 "_thread",
39 "_multiprocessing",
40 "_posixsubprocess",
41 "_asyncio",
42 "array",
43 "math",
44 "cmath",
45];
46const THIRD_PARTY_C_PACKAGES: &[&str] = &[
47 "numpy",
48 "pandas",
49 "scipy",
50 "sklearn",
51 "cv2",
52 "PIL",
53 "torch",
54 "tensorflow",
55 "lxml",
56 "psycopg2",
57 "MySQLdb",
58 "sqlite3",
59 "cryptography",
60 "bcrypt",
61 "regex",
62 "ujson",
63 "orjson",
64 "msgpack",
65 "greenlet",
66 "gevent",
67 "uvloop",
68];
69
70#[derive(Debug, Clone, Copy)]
72pub struct PythonGraphBuilder {
73 max_scope_depth: usize,
74}
75
76impl Default for PythonGraphBuilder {
77 fn default() -> Self {
78 Self {
79 max_scope_depth: DEFAULT_SCOPE_DEPTH,
80 }
81 }
82}
83
84impl PythonGraphBuilder {
85 #[must_use]
86 pub fn new(max_scope_depth: usize) -> Self {
87 Self { max_scope_depth }
88 }
89}
90
91impl GraphBuilder for PythonGraphBuilder {
92 fn build_graph(
93 &self,
94 tree: &Tree,
95 content: &[u8],
96 file: &Path,
97 staging: &mut StagingGraph,
98 ) -> GraphResult<()> {
99 let mut helper = GraphBuildHelper::new(staging, file, Language::Python);
101
102 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
104 GraphBuilderError::ParseError {
105 span: Span::default(),
106 reason: e,
107 }
108 })?;
109
110 let has_all = has_all_assignment(tree.root_node(), content);
112
113 let mut scope_tree = local_scopes::build(tree.root_node(), content)?;
115
116 let recursion_limits =
118 sqry_core::config::RecursionLimits::load_or_default().map_err(|e| {
119 GraphBuilderError::ParseError {
120 span: Span::default(),
121 reason: format!("Failed to load recursion limits: {e}"),
122 }
123 })?;
124 let file_ops_depth = recursion_limits.effective_file_ops_depth().map_err(|e| {
125 GraphBuilderError::ParseError {
126 span: Span::default(),
127 reason: format!("Invalid file_ops_depth configuration: {e}"),
128 }
129 })?;
130 let mut guard =
131 sqry_core::query::security::RecursionGuard::new(file_ops_depth).map_err(|e| {
132 GraphBuilderError::ParseError {
133 span: Span::default(),
134 reason: format!("Failed to create recursion guard: {e}"),
135 }
136 })?;
137
138 walk_tree_for_graph(
140 tree.root_node(),
141 content,
142 &ast_graph,
143 &mut helper,
144 has_all,
145 &mut guard,
146 &mut scope_tree,
147 )?;
148
149 Ok(())
150 }
151
152 fn language(&self) -> Language {
153 Language::Python
154 }
155}
156
157fn has_all_assignment(node: Node, content: &[u8]) -> bool {
159 let mut cursor = node.walk();
160 for child in node.children(&mut cursor) {
161 if child.kind() == "expression_statement" {
162 let assignment = child
164 .children(&mut child.walk())
165 .find(|c| c.kind() == "assignment" || c.kind() == "augmented_assignment");
166
167 if let Some(assignment) = assignment
168 && let Some(left) = assignment.child_by_field_name("left")
169 && let Ok(left_text) = left.utf8_text(content)
170 && left_text.trim() == "__all__"
171 {
172 return true;
173 }
174 }
175 }
176 false
177}
178
179#[allow(clippy::too_many_lines)]
184fn walk_tree_for_graph(
185 node: Node,
186 content: &[u8],
187 ast_graph: &ASTGraph,
188 helper: &mut GraphBuildHelper,
189 has_all: bool,
190 guard: &mut sqry_core::query::security::RecursionGuard,
191 scope_tree: &mut local_scopes::PythonScopeTree,
192) -> GraphResult<()> {
193 guard.enter().map_err(|e| GraphBuilderError::ParseError {
194 span: Span::default(),
195 reason: format!("Recursion limit exceeded: {e}"),
196 })?;
197
198 match node.kind() {
199 "class_definition" => {
200 if let Some(name_node) = node.child_by_field_name("name")
202 && let Ok(class_name) = name_node.utf8_text(content)
203 {
204 let span = span_from_node(node);
205
206 let qualified_name = class_name.to_string();
208
209 let class_id = helper.add_class(&qualified_name, Some(span));
211
212 process_class_inheritance(node, content, class_id, helper);
214
215 if !has_all && is_module_level(node) && is_public_name(class_name) {
219 export_from_file_module(helper, class_id);
220 }
221 }
222 }
223 "expression_statement" => {
224 process_all_assignment(node, content, helper);
226
227 process_annotated_assignment(node, content, ast_graph, helper);
229 }
230 "function_definition" => {
231 if let Some(call_context) = ast_graph.get_callable_context(node.id()) {
233 let span = span_from_node(node);
234
235 let func_name = node
237 .child_by_field_name("name")
238 .and_then(|n| n.utf8_text(content).ok())
239 .unwrap_or("");
240 let visibility = extract_visibility_from_name(func_name);
241
242 let is_property = has_property_decorator(node, content);
244
245 let return_type = extract_return_type_annotation(node, content);
247
248 let function_id = if is_property && call_context.is_method {
250 helper.add_node_with_visibility(
252 &call_context.qualified_name,
253 Some(span),
254 sqry_core::graph::unified::node::NodeKind::Property,
255 Some(visibility),
256 )
257 } else if call_context.is_method {
258 if return_type.is_some() {
260 helper.add_method_with_signature(
261 &call_context.qualified_name,
262 Some(span),
263 call_context.is_async,
264 false, Some(visibility),
266 return_type.as_deref(),
267 )
268 } else {
269 helper.add_method_with_visibility(
270 &call_context.qualified_name,
271 Some(span),
272 call_context.is_async,
273 false,
274 Some(visibility),
275 )
276 }
277 } else {
278 if return_type.is_some() {
280 helper.add_function_with_signature(
281 &call_context.qualified_name,
282 Some(span),
283 call_context.is_async,
284 false, Some(visibility),
286 return_type.as_deref(),
287 )
288 } else {
289 helper.add_function_with_visibility(
290 &call_context.qualified_name,
291 Some(span),
292 call_context.is_async,
293 false,
294 Some(visibility),
295 )
296 }
297 };
298
299 if let Some((http_method, route_path)) = extract_route_decorator_info(node, content)
301 {
302 let endpoint_name = format!("route::{http_method}::{route_path}");
303 let endpoint_id = helper.add_endpoint(&endpoint_name, Some(span));
304 helper.add_contains_edge(endpoint_id, function_id);
305 }
306
307 process_function_parameters(node, content, ast_graph, helper);
309
310 if !has_all
312 && !call_context.is_method
313 && is_module_level(node)
314 && let Some(name_node) = node.child_by_field_name("name")
315 && let Ok(func_name) = name_node.utf8_text(content)
316 && is_public_name(func_name)
317 {
318 export_from_file_module(helper, function_id);
319 }
320 }
321 }
322 "call" => {
323 let is_ffi = build_ffi_call_edge(ast_graph, node, content, helper)?;
325 if !is_ffi {
326 if let Ok(Some((caller_qname, callee_qname, argument_count, is_awaited))) =
328 build_call_for_staging(ast_graph, node, content)
329 {
330 let call_context = ast_graph.get_callable_context(node.id());
332 let _is_async = call_context.is_some_and(|c| c.is_async);
333
334 let call_span = span_from_node(node);
335 let source_id =
336 helper.ensure_callee(&caller_qname, call_span, CalleeKindHint::Function);
337 let target_id =
338 helper.ensure_callee(&callee_qname, call_span, CalleeKindHint::Function);
339
340 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
342 helper.add_call_edge_full_with_span(
343 source_id,
344 target_id,
345 argument_count,
346 is_awaited,
347 vec![call_span],
348 );
349 }
350 }
351 }
352 "import_statement" | "import_from_statement" => {
353 if let Ok(Some((from_qname, to_qname))) =
355 build_import_for_staging(node, content, helper)
356 {
357 let from_id = helper.add_import(&from_qname, None);
359 let to_id = helper.add_import(&to_qname, Some(span_from_node(node)));
360
361 helper.add_import_edge(from_id, to_id);
363
364 if is_native_extension_import(&to_qname) {
366 build_native_import_ffi_edge(&to_qname, node, helper);
367 }
368 }
369 }
370 "identifier" => {
371 local_scopes::handle_identifier_for_reference(node, content, scope_tree, helper);
373 }
374 _ => {}
375 }
376
377 let mut cursor = node.walk();
379 for child in node.children(&mut cursor) {
380 walk_tree_for_graph(
381 child, content, ast_graph, helper, has_all, guard, scope_tree,
382 )?;
383 }
384
385 guard.exit();
386 Ok(())
387}
388
389fn build_call_for_staging(
391 ast_graph: &ASTGraph,
392 call_node: Node<'_>,
393 content: &[u8],
394) -> GraphResult<Option<(String, String, usize, bool)>> {
395 let module_context;
397 let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
398 ctx
399 } else {
400 module_context = CallContext {
402 qualified_name: "<module>".to_string(),
403 span: (0, content.len()),
404 is_async: false,
405 is_method: false,
406 class_name: None,
407 };
408 &module_context
409 };
410
411 let Some(callee_expr) = call_node.child_by_field_name("function") else {
412 return Ok(None);
413 };
414
415 let callee_text = callee_expr
416 .utf8_text(content)
417 .map_err(|_| GraphBuilderError::ParseError {
418 span: span_from_node(call_node),
419 reason: "failed to read call expression".to_string(),
420 })?
421 .trim()
422 .to_string();
423
424 if callee_text.is_empty() {
425 return Ok(None);
426 }
427
428 let callee_simple = simple_name(&callee_text);
429 if callee_simple.is_empty() {
430 return Ok(None);
431 }
432
433 let caller_qname = call_context.qualified_name();
435 let target_qname = if let Some(method_name) = callee_text.strip_prefix("self.") {
436 if let Some(class_name) = &call_context.class_name {
438 format!("{}.{}", class_name, simple_name(method_name))
439 } else {
440 callee_simple.to_string()
441 }
442 } else {
443 callee_simple.to_string()
444 };
445
446 let argument_count = count_arguments(call_node);
447 let is_awaited = is_awaited_call(call_node);
448 Ok(Some((
449 caller_qname,
450 target_qname,
451 argument_count,
452 is_awaited,
453 )))
454}
455
456fn build_import_for_staging(
458 import_node: Node<'_>,
459 content: &[u8],
460 helper: &GraphBuildHelper,
461) -> GraphResult<Option<(String, String)>> {
462 let raw_module_name = if import_node.kind() == "import_statement" {
464 import_node
465 .child_by_field_name("name")
466 .and_then(|n| extract_module_name(n, content))
467 } else if import_node.kind() == "import_from_statement" {
468 import_node
469 .child_by_field_name("module_name")
470 .and_then(|n| extract_module_name(n, content))
471 } else {
472 None
473 };
474
475 let module_name = if raw_module_name.is_none() && import_node.kind() == "import_from_statement"
477 {
478 if let Ok(import_text) = import_node.utf8_text(content) {
479 if let Some(from_idx) = import_text.find("from") {
480 if let Some(import_idx) = import_text.find("import") {
481 let between = import_text[from_idx + 4..import_idx].trim();
482 if between.starts_with('.') {
483 Some(between.to_string())
484 } else {
485 None
486 }
487 } else {
488 None
489 }
490 } else {
491 None
492 }
493 } else {
494 None
495 }
496 } else {
497 raw_module_name
498 };
499
500 let Some(module_name) = module_name else {
501 return Ok(None);
502 };
503
504 if module_name.is_empty() {
505 return Ok(None);
506 }
507
508 let resolved_path = sqry_core::graph::resolve_python_import(
510 std::path::Path::new(helper.file_path()),
511 &module_name,
512 import_node.kind() == "import_from_statement",
513 )?;
514
515 Ok(Some((helper.file_path().to_string(), resolved_path)))
517}
518
519fn span_from_node(node: Node<'_>) -> Span {
520 let start = node.start_position();
521 let end = node.end_position();
522 Span::new(
523 sqry_core::graph::node::Position::new(start.row, start.column),
524 sqry_core::graph::node::Position::new(end.row, end.column),
525 )
526}
527
528fn count_arguments(call_node: Node<'_>) -> usize {
529 call_node
530 .child_by_field_name("arguments")
531 .map_or(0, |args| {
532 args.named_children(&mut args.walk())
533 .filter(|child| {
534 !matches!(child.kind(), "," | "(" | ")")
536 })
537 .count()
538 })
539}
540
541fn is_awaited_call(call_node: Node<'_>) -> bool {
542 let mut current = call_node.parent();
543 while let Some(node) = current {
544 let kind = node.kind();
545 if kind == "await" || kind == "await_expression" {
546 return true;
547 }
548 current = node.parent();
549 }
550 false
551}
552
553fn simple_name(qualified: &str) -> &str {
558 qualified.split('.').next_back().unwrap_or(qualified)
559}
560
561fn ffi_library_simple_name(library_path: &str) -> String {
574 use std::path::Path;
575
576 let filename = Path::new(library_path)
578 .file_name()
579 .and_then(|f| f.to_str())
580 .unwrap_or(library_path);
581
582 if let Some(so_pos) = filename.find(".so.") {
584 return filename[..so_pos].to_string();
585 }
586
587 if let Some(dot_pos) = filename.find('.') {
589 let extension = &filename[dot_pos + 1..];
590
591 if extension == "so" || extension == "dll" || extension == "dylib" {
593 return filename[..dot_pos].to_string();
595 }
596 }
597
598 filename.to_string()
600}
601
602fn is_public_name(name: &str) -> bool {
608 !name.starts_with('_')
609}
610
611fn is_module_level(node: Node<'_>) -> bool {
616 let mut current = node.parent();
618 while let Some(parent) = current {
619 match parent.kind() {
620 "module" => return true,
621 "function_definition" | "class_definition" => return false,
622 _ => current = parent.parent(),
623 }
624 }
625 false
626}
627
628const FILE_MODULE_NAME: &str = "<file_module>";
633
634fn export_from_file_module(
635 helper: &mut GraphBuildHelper,
636 exported: sqry_core::graph::unified::node::NodeId,
637) {
638 let module_id = helper.add_module(FILE_MODULE_NAME, None);
639 helper.add_export_edge(module_id, exported);
640}
641
642fn extract_module_name(node: Node<'_>, content: &[u8]) -> Option<String> {
648 if node.kind() == "aliased_import" {
650 return node
652 .child_by_field_name("name")
653 .and_then(|name_node| name_node.utf8_text(content).ok())
654 .map(std::string::ToString::to_string);
655 }
656
657 node.utf8_text(content)
659 .ok()
660 .map(std::string::ToString::to_string)
661}
662
663fn process_all_assignment(node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
672 let assignment = node
674 .children(&mut node.walk())
675 .find(|child| child.kind() == "assignment" || child.kind() == "augmented_assignment");
676
677 let Some(assignment) = assignment else {
678 return;
679 };
680
681 let left = assignment.child_by_field_name("left");
683 let Some(left) = left else {
684 return;
685 };
686
687 let Ok(left_text) = left.utf8_text(content) else {
688 return;
689 };
690
691 if left_text.trim() != "__all__" {
692 return;
693 }
694
695 let right = assignment.child_by_field_name("right");
697 let Some(right) = right else {
698 return;
699 };
700
701 if right.kind() == "list" || right.kind() == "tuple" {
703 process_all_list(right, content, helper);
704 }
705}
706
707fn process_all_list(list_node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
709 for child in list_node.children(&mut list_node.walk()) {
710 if child.kind() == "string"
712 && let Some(export_name) = extract_string_content(child, content)
713 && !export_name.is_empty()
714 {
715 let span = span_from_node(child);
719 let export_id = helper.add_function(&export_name, Some(span), false, false);
720
721 export_from_file_module(helper, export_id);
723 }
724 }
725}
726
727fn extract_string_content(string_node: Node<'_>, content: &[u8]) -> Option<String> {
729 let Ok(text) = string_node.utf8_text(content) else {
732 return None;
733 };
734
735 let text = text.trim();
736
737 let stripped = text
739 .trim_start_matches(|c: char| {
740 c == 'r'
741 || c == 'b'
742 || c == 'f'
743 || c == 'u'
744 || c == 'R'
745 || c == 'B'
746 || c == 'F'
747 || c == 'U'
748 })
749 .trim_start_matches("'''")
750 .trim_end_matches("'''")
751 .trim_start_matches("\"\"\"")
752 .trim_end_matches("\"\"\"")
753 .trim_start_matches('\'')
754 .trim_end_matches('\'')
755 .trim_start_matches('"')
756 .trim_end_matches('"');
757
758 Some(stripped.to_string())
759}
760
761fn process_class_inheritance(
770 class_node: Node<'_>,
771 content: &[u8],
772 class_id: UnifiedNodeId,
773 helper: &mut GraphBuildHelper,
774) {
775 let superclasses = class_node.child_by_field_name("superclasses");
778
779 let Some(superclasses) = superclasses else {
780 return;
781 };
782
783 for child in superclasses.children(&mut superclasses.walk()) {
785 if child.kind() == "keyword_argument" {
786 continue;
788 }
789
790 match child.kind() {
791 "identifier" => {
792 if let Ok(base_name) = child.utf8_text(content) {
794 let base_name = base_name.trim();
795 if !base_name.is_empty() {
796 let span = span_from_node(child);
797 let base_id = helper.add_class(base_name, Some(span));
798 helper.add_inherits_edge(class_id, base_id);
799 }
800 }
801 }
802 "attribute" => {
803 if let Ok(base_name) = child.utf8_text(content) {
805 let base_name = base_name.trim();
806 if !base_name.is_empty() {
807 let span = span_from_node(child);
808 let base_id = helper.add_class(base_name, Some(span));
809 helper.add_inherits_edge(class_id, base_id);
810 }
811 }
812 }
813 "call" => {
814 if let Some(func) = child.child_by_field_name("function")
817 && let Ok(base_name) = func.utf8_text(content)
818 {
819 let base_name = base_name.trim();
820 if !base_name.is_empty() {
821 let span = span_from_node(child);
822 let base_id = helper.add_class(base_name, Some(span));
823 helper.add_inherits_edge(class_id, base_id);
824 }
825 }
826 }
827 "subscript" => {
828 if let Some(value) = child.child_by_field_name("value")
831 && let Ok(base_name) = value.utf8_text(content)
832 {
833 let base_name = base_name.trim();
834 if !base_name.is_empty() {
835 let span = span_from_node(child);
836 let base_id = helper.add_class(base_name, Some(span));
837 helper.add_inherits_edge(class_id, base_id);
838 }
839 }
840 }
841 _ => {}
842 }
843 }
844}
845
846#[derive(Debug, Clone)]
851struct CallContext {
852 qualified_name: String,
853 #[allow(dead_code)] span: (usize, usize),
855 is_async: bool,
856 is_method: bool,
857 class_name: Option<String>,
858}
859
860impl CallContext {
861 fn qualified_name(&self) -> String {
862 self.qualified_name.clone()
863 }
864}
865
866struct ASTGraph {
867 contexts: Vec<CallContext>,
868 node_to_context: HashMap<usize, usize>,
869}
870
871impl ASTGraph {
872 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
873 let mut contexts = Vec::new();
874 let mut node_to_context = HashMap::new();
875 let mut scope_stack: Vec<String> = Vec::new();
876 let mut class_stack: Vec<String> = Vec::new();
877
878 walk_ast(
879 tree.root_node(),
880 content,
881 &mut contexts,
882 &mut node_to_context,
883 &mut scope_stack,
884 &mut class_stack,
885 max_depth,
886 )?;
887
888 Ok(Self {
889 contexts,
890 node_to_context,
891 })
892 }
893
894 #[allow(dead_code)] fn contexts(&self) -> &[CallContext] {
896 &self.contexts
897 }
898
899 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
900 self.node_to_context
901 .get(&node_id)
902 .and_then(|idx| self.contexts.get(*idx))
903 }
904}
905
906fn walk_ast(
907 node: Node,
908 content: &[u8],
909 contexts: &mut Vec<CallContext>,
910 node_to_context: &mut HashMap<usize, usize>,
911 scope_stack: &mut Vec<String>,
912 class_stack: &mut Vec<String>,
913 max_depth: usize,
914) -> Result<(), String> {
915 if scope_stack.len() > max_depth {
916 return Ok(());
917 }
918
919 match node.kind() {
920 "class_definition" => {
921 let name_node = node
922 .child_by_field_name("name")
923 .ok_or_else(|| "class_definition missing name".to_string())?;
924 let class_name = name_node
925 .utf8_text(content)
926 .map_err(|_| "failed to read class name".to_string())?;
927
928 let qualified_class = if scope_stack.is_empty() {
930 class_name.to_string()
931 } else {
932 format!("{}.{}", scope_stack.join("."), class_name)
933 };
934
935 class_stack.push(qualified_class.clone());
936 scope_stack.push(class_name.to_string());
937
938 if let Some(body) = node.child_by_field_name("body") {
940 let mut cursor = body.walk();
941 for child in body.children(&mut cursor) {
942 walk_ast(
943 child,
944 content,
945 contexts,
946 node_to_context,
947 scope_stack,
948 class_stack,
949 max_depth,
950 )?;
951 }
952 }
953
954 class_stack.pop();
955 scope_stack.pop();
956 }
957 "function_definition" => {
958 let name_node = node
959 .child_by_field_name("name")
960 .ok_or_else(|| "function_definition missing name".to_string())?;
961 let func_name = name_node
962 .utf8_text(content)
963 .map_err(|_| "failed to read function name".to_string())?;
964
965 let is_async = node
967 .children(&mut node.walk())
968 .any(|child| child.kind() == "async");
969
970 let qualified_func = if scope_stack.is_empty() {
972 func_name.to_string()
973 } else {
974 format!("{}.{}", scope_stack.join("."), func_name)
975 };
976
977 let is_method = !class_stack.is_empty();
979 let class_name = class_stack.last().cloned();
980
981 let context_idx = contexts.len();
982 contexts.push(CallContext {
983 qualified_name: qualified_func.clone(),
984 span: (node.start_byte(), node.end_byte()),
985 is_async,
986 is_method,
987 class_name,
988 });
989
990 node_to_context.insert(node.id(), context_idx);
993
994 if let Some(body) = node.child_by_field_name("body") {
996 associate_descendants(body, context_idx, node_to_context);
997 }
998
999 scope_stack.push(func_name.to_string());
1000
1001 if let Some(body) = node.child_by_field_name("body") {
1003 let mut cursor = body.walk();
1004 for child in body.children(&mut cursor) {
1005 walk_ast(
1006 child,
1007 content,
1008 contexts,
1009 node_to_context,
1010 scope_stack,
1011 class_stack,
1012 max_depth,
1013 )?;
1014 }
1015 }
1016
1017 scope_stack.pop();
1018 }
1019 _ => {
1020 let mut cursor = node.walk();
1022 for child in node.children(&mut cursor) {
1023 walk_ast(
1024 child,
1025 content,
1026 contexts,
1027 node_to_context,
1028 scope_stack,
1029 class_stack,
1030 max_depth,
1031 )?;
1032 }
1033 }
1034 }
1035
1036 Ok(())
1037}
1038
1039fn associate_descendants(
1040 node: Node,
1041 context_idx: usize,
1042 node_to_context: &mut HashMap<usize, usize>,
1043) {
1044 node_to_context.insert(node.id(), context_idx);
1045
1046 let mut stack = vec![node];
1047 while let Some(current) = stack.pop() {
1048 node_to_context.insert(current.id(), context_idx);
1049
1050 let mut cursor = current.walk();
1051 for child in current.children(&mut cursor) {
1052 stack.push(child);
1053 }
1054 }
1055}
1056
1057fn build_ffi_call_edge(
1072 ast_graph: &ASTGraph,
1073 call_node: Node<'_>,
1074 content: &[u8],
1075 helper: &mut GraphBuildHelper,
1076) -> GraphResult<bool> {
1077 let Some(callee_expr) = call_node.child_by_field_name("function") else {
1078 return Ok(false);
1079 };
1080
1081 let callee_text = callee_expr
1082 .utf8_text(content)
1083 .map_err(|_| GraphBuilderError::ParseError {
1084 span: span_from_node(call_node),
1085 reason: "failed to read call expression".to_string(),
1086 })?
1087 .trim();
1088
1089 if is_ctypes_load_call(callee_text) {
1091 return Ok(build_ctypes_ffi_edge(
1092 ast_graph,
1093 call_node,
1094 content,
1095 callee_text,
1096 helper,
1097 ));
1098 }
1099
1100 if is_cffi_dlopen_call(callee_text) {
1102 return Ok(build_cffi_ffi_edge(ast_graph, call_node, content, helper));
1103 }
1104
1105 Ok(false)
1106}
1107
1108fn is_ctypes_load_call(callee_text: &str) -> bool {
1117 callee_text == "ctypes.CDLL"
1119 || callee_text == "ctypes.WinDLL"
1120 || callee_text == "ctypes.OleDLL"
1121 || callee_text == "ctypes.PyDLL"
1122 || callee_text == "ctypes.cdll.LoadLibrary"
1124 || callee_text == "ctypes.windll.LoadLibrary"
1125 || callee_text == "ctypes.oledll.LoadLibrary"
1126 || callee_text == "CDLL"
1128 || callee_text == "WinDLL"
1129 || callee_text == "OleDLL"
1130 || callee_text == "PyDLL"
1131 || callee_text == "cdll.LoadLibrary"
1133 || callee_text == "windll.LoadLibrary"
1134 || callee_text == "oledll.LoadLibrary"
1135}
1136
1137fn is_cffi_dlopen_call(callee_text: &str) -> bool {
1142 callee_text == "ffi.dlopen"
1144 || callee_text == "cffi.dlopen"
1145 || callee_text == "_ffi.dlopen"
1146 || callee_text == "FFI().dlopen"
1151}
1152
1153fn build_ctypes_ffi_edge(
1155 ast_graph: &ASTGraph,
1156 call_node: Node<'_>,
1157 content: &[u8],
1158 callee_text: &str,
1159 helper: &mut GraphBuildHelper,
1160) -> bool {
1161 let caller_id = get_ffi_caller_node_id(ast_graph, call_node, content, helper);
1163
1164 let convention = if callee_text.contains("WinDLL")
1166 || callee_text.contains("windll")
1167 || callee_text.contains("OleDLL")
1168 {
1169 FfiConvention::Stdcall
1170 } else {
1171 FfiConvention::C
1172 };
1173
1174 let library_name = extract_ffi_library_name(call_node, content)
1176 .unwrap_or_else(|| "ctypes::unknown".to_string());
1177
1178 let ffi_name = format!("native::{}", ffi_library_simple_name(&library_name));
1179 let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(call_node)));
1180
1181 helper.add_ffi_edge(caller_id, ffi_node_id, convention);
1183
1184 true
1185}
1186
1187fn build_cffi_ffi_edge(
1189 ast_graph: &ASTGraph,
1190 call_node: Node<'_>,
1191 content: &[u8],
1192 helper: &mut GraphBuildHelper,
1193) -> bool {
1194 let caller_id = get_ffi_caller_node_id(ast_graph, call_node, content, helper);
1196
1197 let library_name =
1199 extract_ffi_library_name(call_node, content).unwrap_or_else(|| "cffi::unknown".to_string());
1200
1201 let ffi_name = format!("native::{}", ffi_library_simple_name(&library_name));
1202 let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(call_node)));
1203
1204 helper.add_ffi_edge(caller_id, ffi_node_id, FfiConvention::C);
1206
1207 true
1208}
1209
1210fn get_ffi_caller_node_id(
1212 ast_graph: &ASTGraph,
1213 node: Node<'_>,
1214 content: &[u8],
1215 helper: &mut GraphBuildHelper,
1216) -> UnifiedNodeId {
1217 let module_context;
1218 let call_context = if let Some(ctx) = ast_graph.get_callable_context(node.id()) {
1219 ctx
1220 } else {
1221 module_context = CallContext {
1222 qualified_name: "<module>".to_string(),
1223 span: (0, content.len()),
1224 is_async: false,
1225 is_method: false,
1226 class_name: None,
1227 };
1228 &module_context
1229 };
1230
1231 let caller_span = Some(Span::from_bytes(call_context.span.0, call_context.span.1));
1232 helper.ensure_function(
1233 &call_context.qualified_name(),
1234 caller_span,
1235 call_context.is_async,
1236 false,
1237 )
1238}
1239
1240fn extract_ffi_library_name(call_node: Node<'_>, content: &[u8]) -> Option<String> {
1242 let args = call_node.child_by_field_name("arguments")?;
1243
1244 let mut cursor = args.walk();
1245 let first_arg = args
1246 .children(&mut cursor)
1247 .find(|child| !matches!(child.kind(), "(" | ")" | ","))?;
1248
1249 if first_arg.kind() == "string" {
1251 return extract_string_content(first_arg, content);
1252 }
1253
1254 if first_arg.kind() == "identifier" {
1256 let text = first_arg.utf8_text(content).ok()?;
1257 return Some(format!("${}", text.trim())); }
1259
1260 None
1261}
1262
1263fn is_native_extension_import(module_name: &str) -> bool {
1270 if module_name.starts_with('_') && !module_name.starts_with("__") {
1272 return true;
1273 }
1274
1275 let base_module = module_name.split('.').next().unwrap_or(module_name);
1277
1278 STD_C_MODULES.contains(&base_module) || THIRD_PARTY_C_PACKAGES.contains(&base_module)
1279}
1280
1281fn build_native_import_ffi_edge(
1283 module_name: &str,
1284 import_node: Node<'_>,
1285 helper: &mut GraphBuildHelper,
1286) {
1287 let file_path = helper.file_path().to_string();
1289 let importer_id = helper.add_module(&file_path, None);
1290
1291 let ffi_name = format!("native::{}", simple_name(module_name));
1293 let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(import_node)));
1294
1295 helper.add_ffi_edge(importer_id, ffi_node_id, FfiConvention::C);
1297}
1298
1299const ROUTE_METHOD_NAMES: &[&str] = &["get", "post", "put", "delete", "patch"];
1305
1306const ROUTE_RECEIVER_NAMES: &[&str] = &["app", "router", "blueprint"];
1310
1311fn extract_route_decorator_info(func_node: Node<'_>, content: &[u8]) -> Option<(String, String)> {
1324 let parent = func_node.parent()?;
1326 if parent.kind() != "decorated_definition" {
1327 return None;
1328 }
1329
1330 let mut cursor = parent.walk();
1332 for child in parent.children(&mut cursor) {
1333 if child.kind() != "decorator" {
1334 continue;
1335 }
1336
1337 let Ok(decorator_text) = child.utf8_text(content) else {
1338 continue;
1339 };
1340 let decorator_text = decorator_text.trim();
1341
1342 let without_at = decorator_text.strip_prefix('@')?;
1344
1345 if let Some(result) = parse_route_decorator_text(without_at) {
1347 return Some(result);
1348 }
1349 }
1350
1351 None
1352}
1353
1354fn parse_route_decorator_text(text: &str) -> Option<(String, String)> {
1362 let paren_pos = text.find('(')?;
1365 let accessor = &text[..paren_pos];
1366 let args_text = &text[paren_pos + 1..];
1367
1368 let dot_pos = accessor.rfind('.')?;
1370 let receiver = &accessor[..dot_pos];
1371 let method_name = &accessor[dot_pos + 1..];
1372
1373 let receiver_base = receiver.rsplit('.').next().unwrap_or(receiver);
1376 if !ROUTE_RECEIVER_NAMES.contains(&receiver_base) {
1377 return None;
1378 }
1379
1380 let path = extract_path_from_decorator_args(args_text)?;
1382
1383 let method_lower = method_name.to_ascii_lowercase();
1385 if ROUTE_METHOD_NAMES.contains(&method_lower.as_str()) {
1386 return Some((method_lower.to_ascii_uppercase(), path));
1388 }
1389
1390 if method_lower == "route" {
1391 let http_method = extract_method_from_route_args(args_text);
1393 return Some((http_method, path));
1394 }
1395
1396 None
1397}
1398
1399fn extract_path_from_decorator_args(args_text: &str) -> Option<String> {
1406 let trimmed = args_text.trim();
1407
1408 let (quote_char, start_pos) = {
1410 let single_pos = trimmed.find('\'');
1411 let double_pos = trimmed.find('"');
1412 match (single_pos, double_pos) {
1413 (Some(s), Some(d)) => {
1414 if s < d {
1415 ('\'', s)
1416 } else {
1417 ('"', d)
1418 }
1419 }
1420 (Some(s), None) => ('\'', s),
1421 (None, Some(d)) => ('"', d),
1422 (None, None) => return None,
1423 }
1424 };
1425
1426 let after_open = start_pos + 1;
1428 let close_pos = trimmed[after_open..].find(quote_char)?;
1429 let path = &trimmed[after_open..after_open + close_pos];
1430
1431 if path.is_empty() {
1432 return None;
1433 }
1434
1435 Some(path.to_string())
1436}
1437
1438fn extract_method_from_route_args(args_text: &str) -> String {
1443 let Some(methods_pos) = args_text.find("methods") else {
1445 return "GET".to_string();
1446 };
1447
1448 let after_methods = &args_text[methods_pos..];
1450 let Some(bracket_pos) = after_methods.find('[') else {
1451 return "GET".to_string();
1452 };
1453
1454 let after_bracket = &after_methods[bracket_pos + 1..];
1455
1456 let method_str = extract_first_string_literal(after_bracket);
1458 match method_str {
1459 Some(m) => m.to_ascii_uppercase(),
1460 None => "GET".to_string(),
1461 }
1462}
1463
1464fn extract_first_string_literal(text: &str) -> Option<String> {
1466 let trimmed = text.trim();
1467
1468 let (quote_char, start_pos) = {
1469 let single_pos = trimmed.find('\'');
1470 let double_pos = trimmed.find('"');
1471 match (single_pos, double_pos) {
1472 (Some(s), Some(d)) => {
1473 if s < d {
1474 ('\'', s)
1475 } else {
1476 ('"', d)
1477 }
1478 }
1479 (Some(s), None) => ('\'', s),
1480 (None, Some(d)) => ('"', d),
1481 (None, None) => return None,
1482 }
1483 };
1484
1485 let after_open = start_pos + 1;
1486 let close_pos = trimmed[after_open..].find(quote_char)?;
1487 let literal = &trimmed[after_open..after_open + close_pos];
1488
1489 if literal.is_empty() {
1490 return None;
1491 }
1492
1493 Some(literal.to_string())
1494}
1495
1496fn has_property_decorator(func_node: Node<'_>, content: &[u8]) -> bool {
1517 let Some(parent) = func_node.parent() else {
1519 return false;
1520 };
1521
1522 if parent.kind() != "decorated_definition" {
1524 return false;
1525 }
1526
1527 let mut cursor = parent.walk();
1529 for child in parent.children(&mut cursor) {
1530 if child.kind() == "decorator" {
1531 if let Ok(decorator_text) = child.utf8_text(content) {
1533 let decorator_text = decorator_text.trim();
1534 if decorator_text == "@property"
1536 || decorator_text.starts_with("@property(")
1537 || decorator_text.starts_with("@property (")
1538 {
1539 return true;
1540 }
1541 }
1542 }
1543 }
1544
1545 false
1546}
1547
1548fn extract_visibility_from_name(name: &str) -> &'static str {
1555 if name.starts_with("__") && !name.ends_with("__") {
1556 "private"
1557 } else if name.starts_with('_') {
1558 "protected"
1559 } else {
1560 "public"
1561 }
1562}
1563
1564fn find_containing_scope(node: Node<'_>, content: &[u8], ast_graph: &ASTGraph) -> String {
1576 let mut current = node;
1577 let mut found_class_name: Option<String> = None;
1578
1579 while let Some(parent) = current.parent() {
1581 match parent.kind() {
1582 "function_definition" => {
1583 if let Some(ctx) = ast_graph.get_callable_context(parent.id()) {
1585 return ctx.qualified_name.clone();
1586 }
1587 }
1588 "class_definition" => {
1589 if found_class_name.is_none() {
1592 if let Some(name_node) = parent.child_by_field_name("name")
1594 && let Ok(class_name) = name_node.utf8_text(content)
1595 {
1596 found_class_name = Some(class_name.to_string());
1597 }
1598 }
1599 }
1600 _ => {}
1601 }
1602 current = parent;
1603 }
1604
1605 found_class_name.unwrap_or_default()
1607}
1608
1609fn extract_return_type_annotation(func_node: Node<'_>, content: &[u8]) -> Option<String> {
1616 let return_type_node = func_node.child_by_field_name("return_type")?;
1617 extract_type_from_node(return_type_node, content)
1618}
1619
1620fn process_function_parameters(
1627 func_node: Node<'_>,
1628 content: &[u8],
1629 ast_graph: &ASTGraph,
1630 helper: &mut GraphBuildHelper,
1631) {
1632 let Some(params_node) = func_node.child_by_field_name("parameters") else {
1633 return;
1634 };
1635
1636 let scope_prefix = ast_graph
1638 .get_callable_context(func_node.id())
1639 .map_or("", |ctx| ctx.qualified_name.as_str());
1640
1641 for param in params_node.children(&mut params_node.walk()) {
1643 match param.kind() {
1646 "typed_parameter" | "typed_default_parameter" => {
1647 process_typed_parameter(param, content, scope_prefix, helper);
1648 }
1649 "identifier" | "default_parameter" => {}
1653 _ => {
1654 if param.child_by_field_name("type").is_some() {
1657 process_typed_parameter(param, content, scope_prefix, helper);
1658 }
1659 }
1660 }
1661 }
1662}
1663
1664fn process_typed_parameter(
1669 param: Node<'_>,
1670 content: &[u8],
1671 scope_prefix: &str,
1672 helper: &mut GraphBuildHelper,
1673) {
1674 let param_name = if let Some(name_node) = param.child_by_field_name("name") {
1676 name_node.utf8_text(content).ok()
1677 } else {
1678 param
1680 .children(&mut param.walk())
1681 .find(|c| c.kind() == "identifier")
1682 .and_then(|n| n.utf8_text(content).ok())
1683 };
1684
1685 let Some(param_name) = param_name else {
1686 return;
1687 };
1688
1689 if param_name == "self" || param_name == "cls" {
1691 return;
1692 }
1693
1694 let Some(type_node) = param.child_by_field_name("type") else {
1696 return;
1697 };
1698
1699 let Some(type_name) = extract_type_from_node(type_node, content) else {
1700 return;
1701 };
1702
1703 let qualified_param_name = if scope_prefix.is_empty() {
1706 format!(":{param_name}")
1708 } else {
1709 format!("{scope_prefix}:{param_name}")
1710 };
1711
1712 let param_id = helper.add_variable(&qualified_param_name, Some(span_from_node(param)));
1714
1715 let type_id = helper.add_type(&type_name, None);
1717
1718 helper.add_typeof_edge(param_id, type_id);
1720 helper.add_reference_edge(param_id, type_id);
1721}
1722
1723fn process_annotated_assignment(
1730 expr_stmt_node: Node<'_>,
1731 content: &[u8],
1732 ast_graph: &ASTGraph,
1733 helper: &mut GraphBuildHelper,
1734) {
1735 let scope_prefix = find_containing_scope(expr_stmt_node, content, ast_graph);
1738
1739 for child in expr_stmt_node.children(&mut expr_stmt_node.walk()) {
1741 if child.kind() == "assignment" {
1742 process_typed_assignment(child, content, &scope_prefix, helper);
1743 }
1744 }
1745}
1746
1747fn process_typed_assignment(
1751 assignment_node: Node<'_>,
1752 content: &[u8],
1753 scope_prefix: &str,
1754 helper: &mut GraphBuildHelper,
1755) {
1756 let Some(left) = assignment_node.child_by_field_name("left") else {
1761 return;
1762 };
1763
1764 let Some(type_node) = assignment_node.child_by_field_name("type") else {
1765 return;
1766 };
1767
1768 let Ok(var_name) = left.utf8_text(content) else {
1770 return;
1771 };
1772
1773 let Some(type_name) = extract_type_from_node(type_node, content) else {
1775 return;
1776 };
1777
1778 let qualified_var_name = if scope_prefix.is_empty() {
1782 var_name.to_string()
1784 } else if scope_prefix.contains('.') && !scope_prefix.contains(':') {
1785 format!("{scope_prefix}.{var_name}")
1787 } else {
1788 format!("{scope_prefix}:{var_name}")
1790 };
1791
1792 let var_id = helper.add_variable(&qualified_var_name, Some(span_from_node(assignment_node)));
1794
1795 let type_id = helper.add_type(&type_name, None);
1797
1798 helper.add_typeof_edge(var_id, type_id);
1800 helper.add_reference_edge(var_id, type_id);
1801}
1802
1803fn extract_type_from_node(type_node: Node<'_>, content: &[u8]) -> Option<String> {
1813 match type_node.kind() {
1814 "type" => {
1815 type_node
1817 .named_child(0)
1818 .and_then(|child| extract_type_from_node(child, content))
1819 }
1820 "identifier" => {
1821 type_node.utf8_text(content).ok().map(String::from)
1823 }
1824 "string" => {
1825 let text = type_node.utf8_text(content).ok()?;
1828 let trimmed = text.trim();
1829
1830 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
1832 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
1833 {
1834 let unquoted = &trimmed[1..trimmed.len() - 1];
1835 Some(normalize_union_type(unquoted))
1837 } else {
1838 Some(trimmed.to_string())
1839 }
1840 }
1841 "binary_operator" => {
1842 if let Some(left) = type_node.child_by_field_name("left") {
1845 extract_type_from_node(left, content)
1846 } else {
1847 type_node
1849 .utf8_text(content)
1850 .ok()
1851 .map(|text| normalize_union_type(text.trim()))
1852 }
1853 }
1854 "generic_type" | "subscript" => {
1855 if let Some(value_node) = type_node.child_by_field_name("value") {
1859 extract_type_from_node(value_node, content)
1860 } else {
1861 type_node
1863 .named_child(0)
1864 .and_then(|child| extract_type_from_node(child, content))
1865 .or_else(|| {
1866 type_node.utf8_text(content).ok().and_then(|text| {
1868 text.split('[').next().map(|s| s.trim().to_string())
1870 })
1871 })
1872 }
1873 }
1874 "attribute" => {
1875 type_node.utf8_text(content).ok().map(String::from)
1877 }
1878 "list" | "tuple" | "set" => {
1879 type_node.utf8_text(content).ok().map(String::from)
1881 }
1882 _ => {
1883 let text = type_node.utf8_text(content).ok()?;
1886 let trimmed = text.trim();
1887
1888 if trimmed.contains('[') {
1890 trimmed.split('[').next().map(|s| s.trim().to_string())
1891 } else {
1892 Some(normalize_union_type(trimmed))
1894 }
1895 }
1896 }
1897}
1898
1899fn normalize_union_type(type_str: &str) -> String {
1906 if let Some(pipe_pos) = type_str.find('|') {
1907 type_str[..pipe_pos].trim().to_string()
1909 } else {
1910 type_str.to_string()
1911 }
1912}
1913
1914#[cfg(test)]
1915mod tests {
1916 use super::*;
1917
1918 #[test]
1919 fn test_simple_name_extracts_dotted_identifiers() {
1920 assert_eq!(simple_name("module.func"), "func");
1922 assert_eq!(simple_name("obj.method"), "method");
1923 assert_eq!(simple_name("package.module.func"), "func");
1924 assert_eq!(simple_name("self.helper"), "helper");
1925
1926 assert_eq!(simple_name("function"), "function");
1928 assert_eq!(simple_name(""), "");
1929 }
1930
1931 #[test]
1932 fn test_ffi_library_simple_name_extracts_library_base_names() {
1933 assert_eq!(ffi_library_simple_name("libfoo.so"), "libfoo");
1935 assert_eq!(ffi_library_simple_name("lib1.so"), "lib1");
1936 assert_eq!(ffi_library_simple_name("lib2.so"), "lib2");
1937
1938 assert_eq!(ffi_library_simple_name("kernel32.dll"), "kernel32");
1940 assert_eq!(ffi_library_simple_name("libSystem.dylib"), "libSystem");
1941
1942 assert_eq!(ffi_library_simple_name("libc.so.6"), "libc");
1944
1945 assert_eq!(ffi_library_simple_name("kernel32"), "kernel32");
1947 assert_eq!(ffi_library_simple_name("numpy"), "numpy");
1948
1949 assert_eq!(ffi_library_simple_name("$libname"), "$libname");
1951
1952 assert_eq!(ffi_library_simple_name(""), "");
1954 assert_eq!(ffi_library_simple_name("lib.so"), "lib");
1955 }
1956
1957 #[test]
1958 fn test_ffi_library_simple_name_prevents_duplicate_edges() {
1959 let name1 = ffi_library_simple_name("lib1.so");
1961 let name2 = ffi_library_simple_name("lib2.so");
1962
1963 assert_ne!(
1965 name1, name2,
1966 "lib1.so and lib2.so must produce different simple names"
1967 );
1968 assert_eq!(name1, "lib1");
1969 assert_eq!(name2, "lib2");
1970 }
1971
1972 #[test]
1973 fn test_ffi_library_simple_name_handles_directory_paths() {
1974 assert_eq!(ffi_library_simple_name("/opt/v1.2/libfoo.so"), "libfoo");
1976 assert_eq!(
1977 ffi_library_simple_name("/usr/lib/x86_64-linux-gnu/libc.so.6"),
1978 "libc"
1979 );
1980 assert_eq!(ffi_library_simple_name("libs/lib1.so"), "lib1");
1981
1982 assert_eq!(ffi_library_simple_name("./libs/kernel32.dll"), "kernel32");
1984 assert_eq!(
1985 ffi_library_simple_name("../lib/libSystem.dylib"),
1986 "libSystem"
1987 );
1988 }
1989
1990 #[test]
1995 fn test_parse_route_decorator_app_route_default_get() {
1996 let result = parse_route_decorator_text("app.route('/api/users')");
1997 assert_eq!(result, Some(("GET".to_string(), "/api/users".to_string())));
1998 }
1999
2000 #[test]
2001 fn test_parse_route_decorator_app_route_with_methods_post() {
2002 let result = parse_route_decorator_text("app.route('/api/users', methods=['POST'])");
2003 assert_eq!(result, Some(("POST".to_string(), "/api/users".to_string())));
2004 }
2005
2006 #[test]
2007 fn test_parse_route_decorator_app_route_with_methods_put_double_quotes() {
2008 let result = parse_route_decorator_text("app.route(\"/api/items\", methods=[\"PUT\"])");
2009 assert_eq!(result, Some(("PUT".to_string(), "/api/items".to_string())));
2010 }
2011
2012 #[test]
2013 fn test_parse_route_decorator_app_get() {
2014 let result = parse_route_decorator_text("app.get('/api/users')");
2015 assert_eq!(result, Some(("GET".to_string(), "/api/users".to_string())));
2016 }
2017
2018 #[test]
2019 fn test_parse_route_decorator_app_post() {
2020 let result = parse_route_decorator_text("app.post('/api/items')");
2021 assert_eq!(result, Some(("POST".to_string(), "/api/items".to_string())));
2022 }
2023
2024 #[test]
2025 fn test_parse_route_decorator_app_put() {
2026 let result = parse_route_decorator_text("app.put('/api/items/1')");
2027 assert_eq!(
2028 result,
2029 Some(("PUT".to_string(), "/api/items/1".to_string()))
2030 );
2031 }
2032
2033 #[test]
2034 fn test_parse_route_decorator_app_delete() {
2035 let result = parse_route_decorator_text("app.delete('/api/items/1')");
2036 assert_eq!(
2037 result,
2038 Some(("DELETE".to_string(), "/api/items/1".to_string()))
2039 );
2040 }
2041
2042 #[test]
2043 fn test_parse_route_decorator_app_patch() {
2044 let result = parse_route_decorator_text("app.patch('/api/items/1')");
2045 assert_eq!(
2046 result,
2047 Some(("PATCH".to_string(), "/api/items/1".to_string()))
2048 );
2049 }
2050
2051 #[test]
2052 fn test_parse_route_decorator_router_get_fastapi() {
2053 let result = parse_route_decorator_text("router.get('/api/users')");
2054 assert_eq!(result, Some(("GET".to_string(), "/api/users".to_string())));
2055 }
2056
2057 #[test]
2058 fn test_parse_route_decorator_router_post_fastapi() {
2059 let result = parse_route_decorator_text("router.post('/api/items')");
2060 assert_eq!(result, Some(("POST".to_string(), "/api/items".to_string())));
2061 }
2062
2063 #[test]
2064 fn test_parse_route_decorator_blueprint_route() {
2065 let result = parse_route_decorator_text("blueprint.route('/health')");
2066 assert_eq!(result, Some(("GET".to_string(), "/health".to_string())));
2067 }
2068
2069 #[test]
2070 fn test_parse_route_decorator_unknown_receiver_returns_none() {
2071 let result = parse_route_decorator_text("server.get('/api/users')");
2073 assert_eq!(result, None);
2074 }
2075
2076 #[test]
2077 fn test_parse_route_decorator_unknown_method_returns_none() {
2078 let result = parse_route_decorator_text("app.options('/api/users')");
2080 assert_eq!(result, None);
2081 }
2082
2083 #[test]
2084 fn test_parse_route_decorator_no_parens_returns_none() {
2085 let result = parse_route_decorator_text("app.route");
2086 assert_eq!(result, None);
2087 }
2088
2089 #[test]
2090 fn test_parse_route_decorator_no_dot_returns_none() {
2091 let result = parse_route_decorator_text("route('/api/users')");
2092 assert_eq!(result, None);
2093 }
2094
2095 #[test]
2096 fn test_extract_path_from_decorator_args_single_quotes() {
2097 let result = extract_path_from_decorator_args("'/api/users')");
2098 assert_eq!(result, Some("/api/users".to_string()));
2099 }
2100
2101 #[test]
2102 fn test_extract_path_from_decorator_args_double_quotes() {
2103 let result = extract_path_from_decorator_args("\"/api/items\")");
2104 assert_eq!(result, Some("/api/items".to_string()));
2105 }
2106
2107 #[test]
2108 fn test_extract_path_from_decorator_args_empty_returns_none() {
2109 let result = extract_path_from_decorator_args("'')");
2110 assert_eq!(result, None);
2111 }
2112
2113 #[test]
2114 fn test_extract_path_from_decorator_args_no_string_returns_none() {
2115 let result = extract_path_from_decorator_args("some_var)");
2116 assert_eq!(result, None);
2117 }
2118
2119 #[test]
2120 fn test_extract_method_from_route_args_with_methods_keyword() {
2121 let result = extract_method_from_route_args("'/api/users', methods=['POST'])");
2122 assert_eq!(result, "POST");
2123 }
2124
2125 #[test]
2126 fn test_extract_method_from_route_args_without_methods_keyword() {
2127 let result = extract_method_from_route_args("'/api/users')");
2128 assert_eq!(result, "GET");
2129 }
2130
2131 #[test]
2132 fn test_extract_method_from_route_args_delete() {
2133 let result = extract_method_from_route_args("'/api/items', methods=['DELETE'])");
2134 assert_eq!(result, "DELETE");
2135 }
2136
2137 #[test]
2138 fn test_extract_method_from_route_args_lowercase_normalizes() {
2139 let result = extract_method_from_route_args("'/x', methods=['put'])");
2140 assert_eq!(result, "PUT");
2141 }
2142
2143 #[test]
2144 fn test_extract_first_string_literal_single_quotes() {
2145 let result = extract_first_string_literal("'POST']");
2146 assert_eq!(result, Some("POST".to_string()));
2147 }
2148
2149 #[test]
2150 fn test_extract_first_string_literal_double_quotes() {
2151 let result = extract_first_string_literal("\"DELETE\"]");
2152 assert_eq!(result, Some("DELETE".to_string()));
2153 }
2154
2155 #[test]
2156 fn test_extract_first_string_literal_empty_returns_none() {
2157 let result = extract_first_string_literal("no quotes here");
2158 assert_eq!(result, None);
2159 }
2160}