1pub mod traversal;
19
20use std::collections::HashMap;
21use std::sync::Arc;
22
23use petgraph::Direction;
24use petgraph::graph::{DiGraph, NodeIndex};
25use petgraph::visit::EdgeRef;
26use serde::{Deserialize, Serialize};
27
28use crate::parse::ast::FileId;
29use crate::semantics::common::CommonSemantics;
30use crate::semantics::go::frameworks::GoFrameworkSummary;
31use crate::semantics::go::model::GoFileSemantics;
32use crate::semantics::python::fastapi::FastApiFileSummary;
33use crate::semantics::python::model::PyFileSemantics;
34use crate::semantics::rust::frameworks::RustFrameworkSummary;
35use crate::semantics::rust::model::RustFileSemantics;
36use crate::semantics::typescript::model::{ExpressFileSummary, TsFileSemantics};
37use crate::semantics::{Import, SourceSemantics};
38use crate::types::context::Language;
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub enum ModuleCategory {
43 HttpClient,
45 Database,
47 WebFramework,
49 AsyncRuntime,
51 Logging,
53 Resilience,
55 StandardLib,
57 Other,
59}
60
61impl Default for ModuleCategory {
62 fn default() -> Self {
63 Self::Other
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub enum GraphNode {
70 File {
72 file_id: FileId,
73 path: String,
74 language: Language,
75 },
76
77 Function {
79 file_id: FileId,
80 name: String,
81 qualified_name: String,
83 is_async: bool,
84 is_handler: bool,
86 http_method: Option<String>,
88 http_path: Option<String>,
90 },
91
92 Class {
94 file_id: FileId,
95 name: String,
96 },
97
98 ExternalModule {
100 name: String,
102 category: ModuleCategory,
104 },
105
106 FastApiApp {
108 file_id: FileId,
109 var_name: String,
110 },
111
112 FastApiRoute {
113 file_id: FileId,
114 http_method: String,
115 path: String,
116 },
117
118 FastApiMiddleware {
119 file_id: FileId,
120 app_var_name: String,
121 middleware_type: String,
122 },
123}
124
125impl GraphNode {
126 pub fn file_id(&self) -> Option<FileId> {
128 match self {
129 GraphNode::File { file_id, .. } => Some(*file_id),
130 GraphNode::Function { file_id, .. } => Some(*file_id),
131 GraphNode::Class { file_id, .. } => Some(*file_id),
132 GraphNode::FastApiApp { file_id, .. } => Some(*file_id),
133 GraphNode::FastApiRoute { file_id, .. } => Some(*file_id),
134 GraphNode::FastApiMiddleware { file_id, .. } => Some(*file_id),
135 GraphNode::ExternalModule { .. } => None,
136 }
137 }
138
139 pub fn display_name(&self) -> String {
141 match self {
142 GraphNode::File { path, .. } => path.clone(),
143 GraphNode::Function { qualified_name, .. } => qualified_name.clone(),
144 GraphNode::Class { name, .. } => name.clone(),
145 GraphNode::ExternalModule { name, .. } => name.clone(),
146 GraphNode::FastApiApp { var_name, .. } => format!("FastAPI({})", var_name),
147 GraphNode::FastApiRoute {
148 http_method, path, ..
149 } => format!("{} {}", http_method, path),
150 GraphNode::FastApiMiddleware {
151 middleware_type, ..
152 } => middleware_type.clone(),
153 }
154 }
155
156 pub fn http_method(&self) -> Option<&str> {
158 match self {
159 GraphNode::Function { http_method, .. } => http_method.as_deref(),
160 GraphNode::FastApiRoute { http_method, .. } => Some(http_method),
161 _ => None,
162 }
163 }
164
165 pub fn http_path(&self) -> Option<&str> {
167 match self {
168 GraphNode::Function { http_path, .. } => http_path.as_deref(),
169 GraphNode::FastApiRoute { path, .. } => Some(path),
170 _ => None,
171 }
172 }
173
174 pub fn is_file(&self) -> bool {
176 matches!(self, GraphNode::File { .. })
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub enum GraphEdgeKind {
183 Contains,
185
186 Imports,
189
190 ImportsFrom {
193 items: Vec<String>,
195 },
196
197 Calls,
199
200 Inherits,
202
203 UsesLibrary,
205
206 FastApiAppOwnsRoute,
209
210 FastApiAppHasMiddleware,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct CodeGraph {
217 pub graph: DiGraph<GraphNode, GraphEdgeKind>,
218 #[serde(skip)]
220 pub file_nodes: HashMap<FileId, NodeIndex>,
221 #[serde(skip)]
223 pub path_to_file: HashMap<String, NodeIndex>,
224 #[serde(skip)]
227 pub suffix_to_file: HashMap<String, NodeIndex>,
228 #[serde(skip)]
231 pub module_to_file: HashMap<String, NodeIndex>,
232 #[serde(skip)]
234 pub external_modules: HashMap<String, NodeIndex>,
235 #[serde(skip)]
237 pub function_nodes: HashMap<(FileId, String), NodeIndex>,
238 #[serde(skip)]
240 pub class_nodes: HashMap<(FileId, String), NodeIndex>,
241}
242
243impl CodeGraph {
244 pub fn new() -> Self {
245 Self {
246 graph: DiGraph::new(),
247 file_nodes: HashMap::new(),
248 path_to_file: HashMap::new(),
249 suffix_to_file: HashMap::new(),
250 module_to_file: HashMap::new(),
251 external_modules: HashMap::new(),
252 function_nodes: HashMap::new(),
253 class_nodes: HashMap::new(),
254 }
255 }
256
257 pub fn get_or_create_external_module(
259 &mut self,
260 name: &str,
261 category: ModuleCategory,
262 ) -> NodeIndex {
263 if let Some(&idx) = self.external_modules.get(name) {
264 return idx;
265 }
266 let idx = self.graph.add_node(GraphNode::ExternalModule {
267 name: name.to_string(),
268 category,
269 });
270 self.external_modules.insert(name.to_string(), idx);
271 idx
272 }
273
274 pub fn find_file_by_path(&self, path: &str) -> Option<NodeIndex> {
281 if let Some(&idx) = self.path_to_file.get(path) {
283 return Some(idx);
284 }
285
286 if let Some(&idx) = self.module_to_file.get(path) {
288 return Some(idx);
289 }
290
291 if let Some(&idx) = self.suffix_to_file.get(path) {
293 return Some(idx);
294 }
295
296 if path.contains('.') {
298 let file_path = path.replace('.', "/");
299 for ext in &[".py", ".ts", ".tsx", ".js", ".go", ".rs"] {
301 let full_path = format!("{}{}", file_path, ext);
302 if let Some(&idx) = self.suffix_to_file.get(&full_path) {
303 return Some(idx);
304 }
305 }
306 let init_path = format!("{}/__init__.py", file_path);
308 if let Some(&idx) = self.suffix_to_file.get(&init_path) {
309 return Some(idx);
310 }
311 }
312
313 None
314 }
315
316 pub fn get_importers(&self, file_id: FileId) -> Vec<FileId> {
318 let Some(&target_idx) = self.file_nodes.get(&file_id) else {
319 return vec![];
320 };
321
322 self.graph
323 .edges_directed(target_idx, Direction::Incoming)
324 .filter(|e| {
325 matches!(
326 e.weight(),
327 GraphEdgeKind::Imports | GraphEdgeKind::ImportsFrom { .. }
328 )
329 })
330 .filter_map(|e| {
331 let source_idx = e.source();
332 if let GraphNode::File { file_id, .. } = &self.graph[source_idx] {
333 Some(*file_id)
334 } else {
335 None
336 }
337 })
338 .collect()
339 }
340
341 pub fn get_imports(&self, file_id: FileId) -> Vec<FileId> {
343 let Some(&source_idx) = self.file_nodes.get(&file_id) else {
344 return vec![];
345 };
346
347 self.graph
348 .edges_directed(source_idx, Direction::Outgoing)
349 .filter(|e| {
350 matches!(
351 e.weight(),
352 GraphEdgeKind::Imports | GraphEdgeKind::ImportsFrom { .. }
353 )
354 })
355 .filter_map(|e| {
356 let target_idx = e.target();
357 if let GraphNode::File { file_id, .. } = &self.graph[target_idx] {
358 Some(*file_id)
359 } else {
360 None
361 }
362 })
363 .collect()
364 }
365
366 pub fn get_transitive_importers(
368 &self,
369 file_id: FileId,
370 max_depth: usize,
371 ) -> Vec<(FileId, usize)> {
372 let Some(&start_idx) = self.file_nodes.get(&file_id) else {
373 return vec![];
374 };
375
376 let mut result = Vec::new();
377 let mut visited = std::collections::HashSet::new();
378 let mut queue = std::collections::VecDeque::new();
379
380 visited.insert(start_idx);
381 queue.push_back((start_idx, 0usize));
382
383 while let Some((current_idx, depth)) = queue.pop_front() {
384 if depth >= max_depth {
385 continue;
386 }
387
388 for edge in self.graph.edges_directed(current_idx, Direction::Incoming) {
389 if !matches!(
390 edge.weight(),
391 GraphEdgeKind::Imports | GraphEdgeKind::ImportsFrom { .. }
392 ) {
393 continue;
394 }
395
396 let importer_idx = edge.source();
397 if visited.contains(&importer_idx) {
398 continue;
399 }
400
401 visited.insert(importer_idx);
402
403 if let GraphNode::File { file_id, .. } = &self.graph[importer_idx] {
404 result.push((*file_id, depth + 1));
405 queue.push_back((importer_idx, depth + 1));
406 }
407 }
408 }
409
410 result
411 }
412
413 pub fn get_external_dependencies(&self, file_id: FileId) -> Vec<String> {
415 let Some(&file_idx) = self.file_nodes.get(&file_id) else {
416 return vec![];
417 };
418
419 self.graph
420 .edges_directed(file_idx, Direction::Outgoing)
421 .filter(|e| matches!(e.weight(), GraphEdgeKind::UsesLibrary))
422 .filter_map(|e| {
423 let target_idx = e.target();
424 if let GraphNode::ExternalModule { name, .. } = &self.graph[target_idx] {
425 Some(name.clone())
426 } else {
427 None
428 }
429 })
430 .collect()
431 }
432
433 pub fn get_files_using_library(&self, library_name: &str) -> Vec<FileId> {
435 let Some(&lib_idx) = self.external_modules.get(library_name) else {
436 return vec![];
437 };
438
439 self.graph
440 .edges_directed(lib_idx, Direction::Incoming)
441 .filter(|e| matches!(e.weight(), GraphEdgeKind::UsesLibrary))
442 .filter_map(|e| {
443 let source_idx = e.source();
444 if let GraphNode::File { file_id, .. } = &self.graph[source_idx] {
445 Some(*file_id)
446 } else {
447 None
448 }
449 })
450 .collect()
451 }
452
453 pub fn stats(&self) -> GraphStats {
455 let mut file_count = 0;
456 let mut function_count = 0;
457 let mut class_count = 0;
458 let mut external_module_count = 0;
459
460 for node in self.graph.node_weights() {
461 match node {
462 GraphNode::File { .. } => file_count += 1,
463 GraphNode::Function { .. } => function_count += 1,
464 GraphNode::Class { .. } => class_count += 1,
465 GraphNode::ExternalModule { .. } => external_module_count += 1,
466 _ => {}
467 }
468 }
469
470 let mut import_edge_count = 0;
471 let mut contains_edge_count = 0;
472 let mut uses_library_edge_count = 0;
473 let mut calls_edge_count = 0;
474
475 for edge in self.graph.edge_weights() {
476 match edge {
477 GraphEdgeKind::Imports | GraphEdgeKind::ImportsFrom { .. } => {
478 import_edge_count += 1
479 }
480 GraphEdgeKind::Contains => contains_edge_count += 1,
481 GraphEdgeKind::UsesLibrary => uses_library_edge_count += 1,
482 GraphEdgeKind::Calls => calls_edge_count += 1,
483 _ => {}
484 }
485 }
486
487 GraphStats {
488 file_count,
489 function_count,
490 class_count,
491 external_module_count,
492 import_edge_count,
493 contains_edge_count,
494 uses_library_edge_count,
495 calls_edge_count,
496 total_nodes: self.graph.node_count(),
497 total_edges: self.graph.edge_count(),
498 }
499 }
500
501 pub fn rebuild_indexes(&mut self) {
506 self.file_nodes.clear();
507 self.path_to_file.clear();
508 self.suffix_to_file.clear();
509 self.module_to_file.clear();
510 self.external_modules.clear();
511 self.function_nodes.clear();
512 self.class_nodes.clear();
513
514 for node_idx in self.graph.node_indices() {
515 match &self.graph[node_idx] {
516 GraphNode::File { file_id, path, .. } => {
517 self.file_nodes.insert(*file_id, node_idx);
518 self.path_to_file.insert(path.clone(), node_idx);
519 Self::add_path_to_indexes(
521 path,
522 node_idx,
523 &mut self.suffix_to_file,
524 &mut self.module_to_file,
525 );
526 }
527 GraphNode::Function { file_id, name, .. } => {
528 self.function_nodes
529 .insert((*file_id, name.clone()), node_idx);
530 }
531 GraphNode::Class { file_id, name, .. } => {
532 self.class_nodes.insert((*file_id, name.clone()), node_idx);
533 }
534 GraphNode::ExternalModule { name, .. } => {
535 self.external_modules.insert(name.clone(), node_idx);
536 }
537 _ => {}
538 }
539 }
540 }
541
542 fn add_path_to_indexes(
544 path: &str,
545 node_idx: NodeIndex,
546 suffix_to_file: &mut HashMap<String, NodeIndex>,
547 module_to_file: &mut HashMap<String, NodeIndex>,
548 ) {
549 let parts: Vec<&str> = path.split('/').collect();
552 for i in 0..parts.len() {
553 let suffix: String = parts[i..].join("/");
554 suffix_to_file.entry(suffix).or_insert(node_idx);
556 }
557
558 if let Some(without_ext) = path
561 .strip_suffix(".py")
562 .or_else(|| path.strip_suffix(".ts"))
563 .or_else(|| path.strip_suffix(".tsx"))
564 .or_else(|| path.strip_suffix(".js"))
565 .or_else(|| path.strip_suffix(".go"))
566 .or_else(|| path.strip_suffix(".rs"))
567 {
568 let module_path = without_ext.replace('/', ".");
569 module_to_file
570 .entry(module_path.clone())
571 .or_insert(node_idx);
572
573 let mod_parts: Vec<&str> = module_path.split('.').collect();
575 for i in 0..mod_parts.len() {
576 let partial: String = mod_parts[i..].join(".");
577 module_to_file.entry(partial).or_insert(node_idx);
578 }
579 }
580
581 if path.ends_with("__init__.py") {
583 if let Some(dir_path) = path.strip_suffix("/__init__.py") {
584 let module_path = dir_path.replace('/', ".");
585 module_to_file.entry(module_path).or_insert(node_idx);
586 }
587 }
588 }
589}
590
591impl Default for CodeGraph {
592 fn default() -> Self {
593 Self::new()
594 }
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct GraphStats {
600 pub file_count: usize,
601 pub function_count: usize,
602 pub class_count: usize,
603 pub external_module_count: usize,
604 pub import_edge_count: usize,
605 pub contains_edge_count: usize,
606 pub uses_library_edge_count: usize,
607 pub calls_edge_count: usize,
609 pub total_nodes: usize,
610 pub total_edges: usize,
611}
612
613pub fn build_code_graph(sem_entries: &[(FileId, Arc<SourceSemantics>)]) -> CodeGraph {
618 let mut cg = CodeGraph::new();
619
620 for (file_id, sem) in sem_entries {
622 let (path, language) = match sem.as_ref() {
623 SourceSemantics::Python(py) => (py.path.clone(), Language::Python),
624 SourceSemantics::Go(go) => (go.path.clone(), Language::Go),
625 SourceSemantics::Rust(rs) => (rs.path.clone(), Language::Rust),
626 SourceSemantics::Typescript(ts) => (ts.path.clone(), Language::Typescript),
627 };
628
629 let node_index = cg.graph.add_node(GraphNode::File {
630 file_id: *file_id,
631 path: path.clone(),
632 language,
633 });
634
635 cg.file_nodes.insert(*file_id, node_index);
636 cg.path_to_file.insert(path.clone(), node_index);
637
638 CodeGraph::add_path_to_indexes(
640 &path,
641 node_index,
642 &mut cg.suffix_to_file,
643 &mut cg.module_to_file,
644 );
645 }
646
647 for (file_id, sem) in sem_entries {
649 let file_node = match cg.file_nodes.get(file_id) {
650 Some(idx) => *idx,
651 None => continue,
652 };
653
654 add_import_edges(&mut cg, file_node, *file_id, sem);
656
657 add_function_nodes(&mut cg, file_node, *file_id, sem);
659
660 match sem.as_ref() {
662 SourceSemantics::Python(py) => {
663 if let Some(fastapi) = &py.fastapi {
664 add_fastapi_nodes(&mut cg, file_node, *file_id, py, fastapi);
665 }
666 }
667 SourceSemantics::Go(go) => {
668 if let Some(framework) = &go.go_framework {
670 add_go_framework_nodes(&mut cg, file_node, *file_id, go, framework);
671 }
672 }
673 SourceSemantics::Rust(rs) => {
674 if let Some(framework) = &rs.rust_framework {
676 add_rust_framework_nodes(&mut cg, file_node, *file_id, rs, framework);
677 }
678 }
679 SourceSemantics::Typescript(ts) => {
680 if let Some(express) = &ts.express {
681 add_express_nodes(&mut cg, file_node, *file_id, ts, express);
682 }
683 }
684 }
685 }
686
687 for (file_id, sem) in sem_entries {
690 let functions = match sem.as_ref() {
691 SourceSemantics::Python(py) => py.functions(),
692 SourceSemantics::Go(go) => go.functions(),
693 SourceSemantics::Rust(rs) => rs.functions(),
694 SourceSemantics::Typescript(ts) => ts.functions(),
695 };
696
697 for func in functions {
698 let caller_key = (*file_id, func.name.clone());
700 let Some(&caller_node) = cg.function_nodes.get(&caller_key) else {
701 continue;
702 };
703
704 for call in &func.calls {
706 let callee_key = (*file_id, call.callee.clone());
708 if let Some(&callee_node) = cg.function_nodes.get(&callee_key) {
709 cg.graph
711 .add_edge(caller_node, callee_node, GraphEdgeKind::Calls);
712 }
713 }
714 }
715 }
716
717 add_cross_file_call_edges(&mut cg, sem_entries);
719
720 cg
721}
722
723fn add_cross_file_call_edges(cg: &mut CodeGraph, sem_entries: &[(FileId, Arc<SourceSemantics>)]) {
731 let imports_by_file: HashMap<FileId, (String, Vec<Import>)> = sem_entries
733 .iter()
734 .map(|(file_id, sem)| {
735 let (path, imports) = match sem.as_ref() {
736 SourceSemantics::Python(py) => (py.path.clone(), py.imports()),
737 SourceSemantics::Go(go) => (go.path.clone(), go.imports()),
738 SourceSemantics::Rust(rs) => (rs.path.clone(), rs.imports()),
739 SourceSemantics::Typescript(ts) => (ts.path.clone(), ts.imports()),
740 };
741 (*file_id, (path, imports))
742 })
743 .collect();
744
745 for (file_id, sem) in sem_entries {
747 let functions = match sem.as_ref() {
748 SourceSemantics::Python(py) => py.functions(),
749 SourceSemantics::Go(go) => go.functions(),
750 SourceSemantics::Rust(rs) => rs.functions(),
751 SourceSemantics::Typescript(ts) => ts.functions(),
752 };
753
754 let empty_path = String::new();
755 let empty_imports = Vec::new();
756 let (file_path, imports) = imports_by_file
757 .get(file_id)
758 .map(|(p, i)| (p.as_str(), i.as_slice()))
759 .unwrap_or((empty_path.as_str(), empty_imports.as_slice()));
760
761 for func in functions {
762 let caller_key = (*file_id, func.name.clone());
764 let Some(&caller_node) = cg.function_nodes.get(&caller_key) else {
765 continue;
766 };
767
768 for call in &func.calls {
770 let callee_key = (*file_id, call.callee.clone());
772 if cg.function_nodes.contains_key(&callee_key) {
773 continue;
774 }
775
776 if let Some(callee_node) = resolve_call_through_imports(
778 cg,
779 &call.callee,
780 &call.callee_expr,
781 imports,
782 file_path,
783 ) {
784 cg.graph
785 .add_edge(caller_node, callee_node, GraphEdgeKind::Calls);
786 }
787 }
788 }
789 }
790}
791
792fn resolve_call_through_imports(
804 cg: &CodeGraph,
805 callee: &str,
806 callee_expr: &str,
807 imports: &[Import],
808 importing_file_path: &str,
809) -> Option<NodeIndex> {
810 for import in imports {
814 if import.imports_item(callee) {
816 if let Some(source_file_idx) =
818 find_import_source_file_with_context(cg, &import.module_path, importing_file_path)
819 {
820 if let GraphNode::File { file_id, .. } = &cg.graph[source_file_idx] {
822 let callee_key = (*file_id, callee.to_string());
824 if let Some(&func_node) = cg.function_nodes.get(&callee_key) {
825 return Some(func_node);
826 }
827 }
828 }
829 }
830 }
831
832 if callee_expr.contains('.') {
836 let parts: Vec<&str> = callee_expr.split('.').collect();
837 if parts.len() >= 2 {
838 let module_alias = parts[0];
839 let func_name = parts[parts.len() - 1];
840
841 for import in imports {
842 let matches_alias = import.module_alias.as_deref() == Some(module_alias)
844 || import.local_module_name() == Some(module_alias);
845
846 if matches_alias {
847 if let Some(source_file_idx) = find_import_source_file_with_context(
848 cg,
849 &import.module_path,
850 importing_file_path,
851 ) {
852 if let GraphNode::File { file_id, .. } = &cg.graph[source_file_idx] {
853 let callee_key = (*file_id, func_name.to_string());
854 if let Some(&func_node) = cg.function_nodes.get(&callee_key) {
855 return Some(func_node);
856 }
857 }
858 }
859 }
860 }
861 }
862 }
863
864 None
865}
866
867fn find_import_source_file_with_context(
877 cg: &CodeGraph,
878 module_path: &str,
879 importing_file_path: &str,
880) -> Option<NodeIndex> {
881 if module_path.starts_with('.') {
883 let possible_paths = resolve_relative_import(importing_file_path, module_path);
885 for path in &possible_paths {
886 if let Some(idx) = cg.find_file_by_path(path) {
887 return Some(idx);
888 }
889 }
890 return None;
891 }
892
893 find_import_source_file(cg, module_path)
895}
896
897fn find_import_source_file(cg: &CodeGraph, module_path: &str) -> Option<NodeIndex> {
903 if module_path.starts_with('.') {
906 return None;
907 }
908
909 if module_path.contains("::") {
911 let stripped = module_path.strip_prefix("crate::").unwrap_or(module_path);
912 for path in rust_path_candidates(stripped) {
913 if let Some(idx) = cg.find_file_by_path(&path) {
914 return Some(idx);
915 }
916 }
917 return None;
918 }
919
920 let module_as_file = module_path.replace('.', "/");
922 let possible_paths = [
923 format!("{}.py", module_as_file),
924 format!("{}/__init__.py", module_as_file),
925 format!("{}.ts", module_as_file),
926 format!("{}.tsx", module_as_file),
927 format!("{}.js", module_as_file),
928 format!("{}.go", module_as_file),
929 format!("{}.rs", module_as_file),
930 module_as_file.clone(),
931 ];
932
933 for path in &possible_paths {
934 if let Some(idx) = cg.find_file_by_path(path) {
935 return Some(idx);
936 }
937 }
938
939 None
940}
941
942fn add_import_edges(
944 cg: &mut CodeGraph,
945 file_node: NodeIndex,
946 _file_id: FileId,
947 sem: &Arc<SourceSemantics>,
948) {
949 let file_path = match sem.as_ref() {
951 SourceSemantics::Python(py) => py.path.clone(),
952 SourceSemantics::Go(go) => go.path.clone(),
953 SourceSemantics::Rust(rs) => rs.path.clone(),
954 SourceSemantics::Typescript(ts) => ts.path.clone(),
955 };
956
957 let imports = match sem.as_ref() {
959 SourceSemantics::Python(py) => py.imports(),
960 SourceSemantics::Go(go) => go.imports(),
961 SourceSemantics::Rust(rs) => rs.imports(),
962 SourceSemantics::Typescript(ts) => ts.imports(),
963 };
964
965 for import in imports {
966 let possible_paths = if import.module_path.starts_with('.') {
977 resolve_relative_import(&file_path, &import.module_path)
979 } else if import.module_path.contains("::") {
980 resolve_rust_use_path(&file_path, &import.module_path)
982 } else {
983 let module_as_file = import.module_path.replace('.', "/");
985 vec![
986 format!("{}.py", module_as_file),
987 format!("{}/__init__.py", module_as_file),
988 format!("{}.ts", module_as_file),
989 format!("{}.tsx", module_as_file),
990 format!("{}.js", module_as_file),
991 module_as_file.clone(),
992 ]
993 };
994
995 let mut found_local_file = false;
996 for path in &possible_paths {
997 if let Some(target_idx) = cg.find_file_by_path(path) {
998 if import.items.is_empty() {
1000 cg.graph
1001 .add_edge(file_node, target_idx, GraphEdgeKind::Imports);
1002 } else {
1003 let items: Vec<String> = import.items.iter().map(|i| i.name.clone()).collect();
1004 cg.graph
1005 .add_edge(file_node, target_idx, GraphEdgeKind::ImportsFrom { items });
1006 }
1007 found_local_file = true;
1008 break;
1009 }
1010 }
1011
1012 if !found_local_file {
1014 let category = categorize_module(&import.module_path);
1015 let package_name = import.package_name().to_string();
1016 let module_idx = cg.get_or_create_external_module(&package_name, category);
1017 cg.graph
1018 .add_edge(file_node, module_idx, GraphEdgeKind::UsesLibrary);
1019 }
1020 }
1021}
1022
1023fn resolve_relative_import(importing_file: &str, module_path: &str) -> Vec<String> {
1039 let dots = module_path.chars().take_while(|&c| c == '.').count();
1041 let remaining = &module_path[dots..];
1042
1043 let importing_dir = if let Some(last_slash) = importing_file.rfind('/') {
1045 &importing_file[..last_slash]
1046 } else {
1047 ""
1049 };
1050
1051 let mut base_dir = importing_dir.to_string();
1053 for _ in 0..(dots.saturating_sub(1)) {
1054 if let Some(last_slash) = base_dir.rfind('/') {
1055 base_dir = base_dir[..last_slash].to_string();
1056 } else {
1057 base_dir = String::new();
1058 break;
1059 }
1060 }
1061
1062 let module_as_path = remaining.replace('.', "/");
1064
1065 let resolved_base = if base_dir.is_empty() {
1067 module_as_path
1068 } else if module_as_path.is_empty() {
1069 base_dir
1071 } else {
1072 format!("{}/{}", base_dir, module_as_path)
1073 };
1074
1075 vec![
1077 format!("{}.py", resolved_base),
1078 format!("{}/__init__.py", resolved_base),
1079 format!("{}.ts", resolved_base),
1080 format!("{}.tsx", resolved_base),
1081 format!("{}.js", resolved_base),
1082 resolved_base,
1083 ]
1084}
1085
1086fn resolve_rust_use_path(importing_file: &str, module_path: &str) -> Vec<String> {
1093 if let Some(rest) = module_path.strip_prefix("crate::") {
1094 rust_path_candidates(rest)
1095 } else if module_path.starts_with("super::") {
1096 let mut rest: &str = module_path;
1097 let mut levels: usize = 0;
1098 while let Some(after) = rest.strip_prefix("super::") {
1099 levels += 1;
1100 rest = after;
1101 }
1102 if rest == "super" {
1103 levels += 1;
1104 rest = "";
1105 }
1106
1107 let importing_dir = importing_file
1108 .rfind('/')
1109 .map(|i| &importing_file[..i])
1110 .unwrap_or("");
1111 let is_mod_rs = importing_file.ends_with("/mod.rs") || importing_file == "mod.rs";
1112 let extra = if is_mod_rs { 1 } else { 0 };
1113
1114 let mut base_dir = importing_dir.to_string();
1115 for _ in 0..(levels + extra).saturating_sub(1) {
1116 if let Some(last_slash) = base_dir.rfind('/') {
1117 base_dir = base_dir[..last_slash].to_string();
1118 } else {
1119 base_dir = String::new();
1120 break;
1121 }
1122 }
1123
1124 let segments: Vec<&str> = if rest.is_empty() {
1125 vec![]
1126 } else {
1127 rest.split("::").collect()
1128 };
1129 let mut paths = Vec::new();
1130 for len in (1..=segments.len()).rev() {
1131 let module_as_path = segments[..len].join("/");
1132 let resolved = if base_dir.is_empty() {
1133 module_as_path
1134 } else {
1135 format!("{}/{}", base_dir, module_as_path)
1136 };
1137 paths.push(format!("{}.rs", resolved));
1138 paths.push(format!("{}/mod.rs", resolved));
1139 }
1140 if segments.is_empty() && !base_dir.is_empty() {
1141 paths.push(format!("{}.rs", base_dir));
1142 paths.push(format!("{}/mod.rs", base_dir));
1143 }
1144 paths
1145 } else if let Some(rest) = module_path.strip_prefix("self::") {
1146 let importing_dir = importing_file
1147 .rfind('/')
1148 .map(|i| &importing_file[..i])
1149 .unwrap_or("");
1150 let segments: Vec<&str> = rest.split("::").collect();
1151 let mut paths = Vec::new();
1152 for len in (1..=segments.len()).rev() {
1153 let module_as_path = segments[..len].join("/");
1154 let resolved = if importing_dir.is_empty() {
1155 module_as_path
1156 } else {
1157 format!("{}/{}", importing_dir, module_as_path)
1158 };
1159 paths.push(format!("{}.rs", resolved));
1160 paths.push(format!("{}/mod.rs", resolved));
1161 }
1162 paths
1163 } else {
1164 rust_path_candidates(&module_path.replace("::", "/"))
1166 }
1167}
1168
1169fn rust_path_candidates(path: &str) -> Vec<String> {
1174 let segments: Vec<&str> = if path.contains("::") {
1175 path.split("::").collect()
1176 } else {
1177 path.split('/').collect()
1178 };
1179
1180 let mut paths = Vec::new();
1181 for len in (1..=segments.len()).rev() {
1182 let module_as_file = segments[..len].join("/");
1183 paths.push(format!("{}.rs", module_as_file));
1184 paths.push(format!("{}/mod.rs", module_as_file));
1185 paths.push(format!("src/{}.rs", module_as_file));
1186 paths.push(format!("src/{}/mod.rs", module_as_file));
1187 }
1188 paths
1189}
1190
1191fn add_function_nodes(
1197 cg: &mut CodeGraph,
1198 file_node: NodeIndex,
1199 file_id: FileId,
1200 sem: &Arc<SourceSemantics>,
1201) {
1202 let functions = match sem.as_ref() {
1204 SourceSemantics::Python(py) => py.functions(),
1205 SourceSemantics::Go(go) => go.functions(),
1206 SourceSemantics::Rust(rs) => rs.functions(),
1207 SourceSemantics::Typescript(ts) => ts.functions(),
1208 };
1209
1210 let handler_names_to_skip: std::collections::HashSet<&str> = match sem.as_ref() {
1213 SourceSemantics::Python(py) => {
1214 if let Some(fastapi) = &py.fastapi {
1216 fastapi
1217 .routes
1218 .iter()
1219 .map(|r| r.handler_name.as_str())
1220 .collect()
1221 } else {
1222 std::collections::HashSet::new()
1223 }
1224 }
1225 SourceSemantics::Typescript(ts) => {
1226 if let Some(express) = &ts.express {
1228 express
1229 .routes
1230 .iter()
1231 .filter_map(|r| r.handler_name.as_deref())
1232 .collect()
1233 } else {
1234 std::collections::HashSet::new()
1235 }
1236 }
1237 SourceSemantics::Go(go) => {
1238 if let Some(framework) = &go.go_framework {
1240 framework
1241 .routes
1242 .iter()
1243 .filter_map(|r| r.handler_name.as_deref())
1244 .collect()
1245 } else {
1246 std::collections::HashSet::new()
1247 }
1248 }
1249 SourceSemantics::Rust(_rs) => {
1250 std::collections::HashSet::new()
1252 }
1253 };
1254
1255 for func in functions {
1256 if handler_names_to_skip.contains(func.name.as_str()) {
1258 continue;
1259 }
1260
1261 let qualified_name = match &func.class_name {
1262 Some(class) => format!("{}.{}", class, func.name),
1263 None => func.name.clone(),
1264 };
1265
1266 let func_node = cg.graph.add_node(GraphNode::Function {
1267 file_id,
1268 name: func.name.clone(),
1269 qualified_name: qualified_name.clone(),
1270 is_async: func.is_async,
1271 is_handler: func.is_route_handler(),
1272 http_method: None,
1273 http_path: None,
1274 });
1275
1276 cg.graph
1278 .add_edge(file_node, func_node, GraphEdgeKind::Contains);
1279
1280 cg.function_nodes
1282 .insert((file_id, func.name.clone()), func_node);
1283 }
1284}
1285
1286fn categorize_module(module_path: &str) -> ModuleCategory {
1288 let path_lower = module_path.to_lowercase();
1289
1290 if path_lower == "logging"
1292 || path_lower.starts_with("logging.")
1293 || path_lower.contains("structlog")
1294 || path_lower == "tracing"
1295 || path_lower.starts_with("tracing::")
1296 || path_lower.contains("uber.org/zap")
1297 || path_lower.contains("zerolog")
1298 || path_lower == "winston"
1299 || path_lower == "pino"
1300 {
1301 return ModuleCategory::Logging;
1302 }
1303
1304 if path_lower.contains("requests")
1306 || path_lower.contains("httpx")
1307 || path_lower.contains("aiohttp")
1308 || path_lower.contains("urllib")
1309 || path_lower.contains("axios")
1310 || path_lower.contains("fetch")
1311 || path_lower.contains("got")
1312 || path_lower.contains("reqwest")
1313 || path_lower.contains("hyper")
1314 {
1315 return ModuleCategory::HttpClient;
1316 }
1317
1318 if path_lower.contains("sqlalchemy")
1320 || path_lower.contains("prisma")
1321 || path_lower.contains("typeorm")
1322 || path_lower.contains("sequelize")
1323 || path_lower.contains("diesel")
1324 || path_lower.contains("sqlx")
1325 || path_lower.contains("gorm")
1326 || path_lower.contains("database/sql")
1327 {
1328 return ModuleCategory::Database;
1329 }
1330
1331 if path_lower.contains("fastapi")
1333 || path_lower.contains("flask")
1334 || path_lower.contains("django")
1335 || path_lower.contains("express")
1336 || path_lower.contains("nestjs")
1337 || path_lower.contains("gin")
1338 || path_lower.contains("echo")
1339 || path_lower.contains("chi")
1340 || path_lower.contains("axum")
1341 || path_lower.contains("actix")
1342 {
1343 return ModuleCategory::WebFramework;
1344 }
1345
1346 if path_lower.contains("asyncio")
1348 || path_lower.contains("tokio")
1349 || path_lower.contains("async_std")
1350 {
1351 return ModuleCategory::AsyncRuntime;
1352 }
1353
1354 if path_lower.contains("tenacity")
1356 || path_lower.contains("stamina")
1357 || path_lower.contains("retry")
1358 || path_lower.contains("backoff")
1359 || path_lower.contains("resilience")
1360 {
1361 return ModuleCategory::Resilience;
1362 }
1363
1364 if module_path.starts_with("os")
1366 || module_path.starts_with("sys")
1367 || module_path.starts_with("io")
1368 || module_path.starts_with("time")
1369 || module_path.starts_with("json")
1370 || module_path.starts_with("re")
1371 || module_path.starts_with("collections")
1372 || module_path.starts_with("typing")
1373 || module_path.starts_with("pathlib")
1374 || module_path.starts_with("fmt")
1375 || module_path.starts_with("net/")
1376 || module_path.starts_with("std::")
1377 {
1378 return ModuleCategory::StandardLib;
1379 }
1380
1381 ModuleCategory::Other
1382}
1383
1384fn add_fastapi_nodes(
1385 cg: &mut CodeGraph,
1386 file_node: NodeIndex,
1387 file_id: FileId,
1388 py: &PyFileSemantics,
1389 fastapi: &FastApiFileSummary,
1390) {
1391 let mut app_nodes: HashMap<String, NodeIndex> = HashMap::new();
1393
1394 for app in &fastapi.apps {
1396 let app_node = cg.graph.add_node(GraphNode::FastApiApp {
1397 file_id,
1398 var_name: app.var_name.clone(),
1399 });
1400
1401 cg.graph
1403 .add_edge(file_node, app_node, GraphEdgeKind::Contains);
1404
1405 app_nodes.insert(app.var_name.clone(), app_node);
1406 }
1407
1408 for route in &fastapi.routes {
1410 let route_node = cg.graph.add_node(GraphNode::FastApiRoute {
1411 file_id,
1412 http_method: route.http_method.clone(),
1413 path: route.path.clone(),
1414 });
1415
1416 cg.graph
1418 .add_edge(file_node, route_node, GraphEdgeKind::Contains);
1419
1420 for app_node in app_nodes.values() {
1424 cg.graph
1425 .add_edge(*app_node, route_node, GraphEdgeKind::FastApiAppOwnsRoute);
1426 }
1427
1428 let qualified_name = route.handler_name.clone();
1430 let func_node = cg.graph.add_node(GraphNode::Function {
1431 file_id,
1432 name: route.handler_name.clone(),
1433 qualified_name,
1434 is_async: route.is_async,
1435 is_handler: true,
1436 http_method: Some(route.http_method.clone()),
1437 http_path: Some(route.path.clone()),
1438 });
1439
1440 cg.graph
1442 .add_edge(file_node, func_node, GraphEdgeKind::Contains);
1443
1444 cg.graph
1446 .add_edge(func_node, route_node, GraphEdgeKind::Contains);
1447
1448 cg.function_nodes
1450 .insert((file_id, route.handler_name.clone()), func_node);
1451 }
1452
1453 for mw in &fastapi.middlewares {
1455 let mw_node = cg.graph.add_node(GraphNode::FastApiMiddleware {
1456 file_id,
1457 app_var_name: mw.app_var_name.clone(),
1458 middleware_type: mw.middleware_type.clone(),
1459 });
1460
1461 cg.graph
1463 .add_edge(file_node, mw_node, GraphEdgeKind::Contains);
1464
1465 if let Some(app_node) = app_nodes.get(&mw.app_var_name) {
1467 cg.graph
1468 .add_edge(*app_node, mw_node, GraphEdgeKind::FastApiAppHasMiddleware);
1469 }
1470 }
1471
1472 let _ = py; }
1474
1475fn add_express_nodes(
1480 cg: &mut CodeGraph,
1481 file_node: NodeIndex,
1482 file_id: FileId,
1483 _ts: &TsFileSemantics,
1484 express: &ExpressFileSummary,
1485) {
1486 for route in &express.routes {
1488 let handler_name = match &route.handler_name {
1490 Some(name) => name.clone(),
1491 None => continue, };
1493
1494 if cg
1496 .function_nodes
1497 .contains_key(&(file_id, handler_name.clone()))
1498 {
1499 continue;
1503 }
1504
1505 let http_method = route.method.to_uppercase();
1506 let http_path = route.path.clone();
1507
1508 let func_node = cg.graph.add_node(GraphNode::Function {
1509 file_id,
1510 name: handler_name.clone(),
1511 qualified_name: handler_name.clone(),
1512 is_async: route.is_async,
1513 is_handler: true,
1514 http_method: Some(http_method),
1515 http_path,
1516 });
1517
1518 cg.graph
1520 .add_edge(file_node, func_node, GraphEdgeKind::Contains);
1521
1522 cg.function_nodes.insert((file_id, handler_name), func_node);
1524 }
1525}
1526
1527fn add_go_framework_nodes(
1533 cg: &mut CodeGraph,
1534 file_node: NodeIndex,
1535 file_id: FileId,
1536 _go: &GoFileSemantics,
1537 framework: &GoFrameworkSummary,
1538) {
1539 for route in &framework.routes {
1541 let handler_name = match &route.handler_name {
1543 Some(name) => name.clone(),
1544 None => continue, };
1546
1547 if cg
1549 .function_nodes
1550 .contains_key(&(file_id, handler_name.clone()))
1551 {
1552 continue;
1554 }
1555
1556 let func_node = cg.graph.add_node(GraphNode::Function {
1557 file_id,
1558 name: handler_name.clone(),
1559 qualified_name: handler_name.clone(),
1560 is_async: false, is_handler: true,
1562 http_method: Some(route.http_method.clone()),
1563 http_path: Some(route.path.clone()),
1564 });
1565
1566 cg.graph
1568 .add_edge(file_node, func_node, GraphEdgeKind::Contains);
1569
1570 cg.function_nodes.insert((file_id, handler_name), func_node);
1572 }
1573}
1574
1575fn add_rust_framework_nodes(
1581 cg: &mut CodeGraph,
1582 file_node: NodeIndex,
1583 file_id: FileId,
1584 _rs: &RustFileSemantics,
1585 framework: &RustFrameworkSummary,
1586) {
1587 for route in &framework.routes {
1589 let handler_name = route.handler_name.clone();
1590
1591 if cg
1593 .function_nodes
1594 .contains_key(&(file_id, handler_name.clone()))
1595 {
1596 continue;
1598 }
1599
1600 let func_node = cg.graph.add_node(GraphNode::Function {
1601 file_id,
1602 name: handler_name.clone(),
1603 qualified_name: handler_name.clone(),
1604 is_async: route.is_async,
1605 is_handler: true,
1606 http_method: Some(route.method.clone()),
1607 http_path: Some(route.path.clone()),
1608 });
1609
1610 cg.graph
1612 .add_edge(file_node, func_node, GraphEdgeKind::Contains);
1613
1614 cg.function_nodes.insert((file_id, handler_name), func_node);
1616 }
1617}
1618
1619#[cfg(test)]
1620mod tests {
1621 use super::*;
1622 use crate::parse::ast::FileId;
1623 use crate::parse::python::parse_python_file;
1624 use crate::semantics::SourceSemantics;
1625 use crate::semantics::python::model::PyFileSemantics;
1626 use crate::types::context::{Language, SourceFile};
1627
1628 fn parse_and_build_semantics(path: &str, source: &str) -> (FileId, Arc<SourceSemantics>) {
1630 let sf = SourceFile {
1631 path: path.to_string(),
1632 language: Language::Python,
1633 content: source.to_string(),
1634 };
1635 let file_id = FileId(1);
1636 let parsed = parse_python_file(file_id, &sf).expect("parsing should succeed");
1637 let mut sem = PyFileSemantics::from_parsed(&parsed);
1638 sem.analyze_frameworks(&parsed)
1639 .expect("framework analysis should succeed");
1640 (file_id, Arc::new(SourceSemantics::Python(sem)))
1641 }
1642
1643 fn parse_python_with_id(path: &str, source: &str, id: u64) -> (FileId, Arc<SourceSemantics>) {
1644 let sf = SourceFile {
1645 path: path.to_string(),
1646 language: Language::Python,
1647 content: source.to_string(),
1648 };
1649 let file_id = FileId(id);
1650 let parsed = parse_python_file(file_id, &sf).expect("parsing should succeed");
1651 let mut sem = PyFileSemantics::from_parsed(&parsed);
1652 sem.analyze_frameworks(&parsed)
1653 .expect("framework analysis should succeed");
1654 (file_id, Arc::new(SourceSemantics::Python(sem)))
1655 }
1656
1657 #[test]
1660 fn code_graph_new_creates_empty_graph() {
1661 let cg = CodeGraph::new();
1662 assert_eq!(cg.graph.node_count(), 0);
1663 assert_eq!(cg.graph.edge_count(), 0);
1664 assert!(cg.file_nodes.is_empty());
1665 }
1666
1667 #[test]
1668 fn code_graph_debug_impl() {
1669 let cg = CodeGraph::new();
1670 let debug_str = format!("{:?}", cg);
1671 assert!(debug_str.contains("CodeGraph"));
1672 }
1673
1674 #[test]
1675 fn code_graph_default_impl() {
1676 let cg = CodeGraph::default();
1677 assert_eq!(cg.graph.node_count(), 0);
1678 }
1679
1680 #[test]
1683 fn graph_node_file_debug() {
1684 let node = GraphNode::File {
1685 file_id: FileId(1),
1686 path: "test.py".to_string(),
1687 language: Language::Python,
1688 };
1689 let debug_str = format!("{:?}", node);
1690 assert!(debug_str.contains("File"));
1691 assert!(debug_str.contains("test.py"));
1692 }
1693
1694 #[test]
1695 fn graph_node_function_debug() {
1696 let node = GraphNode::Function {
1697 file_id: FileId(1),
1698 name: "my_func".to_string(),
1699 qualified_name: "MyClass.my_func".to_string(),
1700 is_async: true,
1701 is_handler: false,
1702 http_method: None,
1703 http_path: None,
1704 };
1705 let debug_str = format!("{:?}", node);
1706 assert!(debug_str.contains("Function"));
1707 assert!(debug_str.contains("my_func"));
1708 }
1709
1710 #[test]
1711 fn graph_node_class_debug() {
1712 let node = GraphNode::Class {
1713 file_id: FileId(1),
1714 name: "MyClass".to_string(),
1715 };
1716 let debug_str = format!("{:?}", node);
1717 assert!(debug_str.contains("Class"));
1718 assert!(debug_str.contains("MyClass"));
1719 }
1720
1721 #[test]
1722 fn graph_node_external_module_debug() {
1723 let node = GraphNode::ExternalModule {
1724 name: "requests".to_string(),
1725 category: ModuleCategory::HttpClient,
1726 };
1727 let debug_str = format!("{:?}", node);
1728 assert!(debug_str.contains("ExternalModule"));
1729 assert!(debug_str.contains("requests"));
1730 }
1731
1732 #[test]
1733 fn graph_node_fastapi_app_debug() {
1734 let node = GraphNode::FastApiApp {
1735 file_id: FileId(1),
1736 var_name: "app".to_string(),
1737 };
1738 let debug_str = format!("{:?}", node);
1739 assert!(debug_str.contains("FastApiApp"));
1740 assert!(debug_str.contains("app"));
1741 }
1742
1743 #[test]
1744 fn graph_node_fastapi_route_debug() {
1745 let node = GraphNode::FastApiRoute {
1746 file_id: FileId(1),
1747 http_method: "GET".to_string(),
1748 path: "/users".to_string(),
1749 };
1750 let debug_str = format!("{:?}", node);
1751 assert!(debug_str.contains("FastApiRoute"));
1752 assert!(debug_str.contains("GET"));
1753 }
1754
1755 #[test]
1756 fn graph_node_fastapi_middleware_debug() {
1757 let node = GraphNode::FastApiMiddleware {
1758 file_id: FileId(1),
1759 app_var_name: "app".to_string(),
1760 middleware_type: "CORSMiddleware".to_string(),
1761 };
1762 let debug_str = format!("{:?}", node);
1763 assert!(debug_str.contains("FastApiMiddleware"));
1764 assert!(debug_str.contains("CORSMiddleware"));
1765 }
1766
1767 #[test]
1768 fn graph_node_display_name() {
1769 let file = GraphNode::File {
1770 file_id: FileId(1),
1771 path: "src/main.py".to_string(),
1772 language: Language::Python,
1773 };
1774 assert_eq!(file.display_name(), "src/main.py");
1775
1776 let func = GraphNode::Function {
1777 file_id: FileId(1),
1778 name: "process".to_string(),
1779 qualified_name: "Handler.process".to_string(),
1780 is_async: false,
1781 is_handler: true,
1782 http_method: None,
1783 http_path: None,
1784 };
1785 assert_eq!(func.display_name(), "Handler.process");
1786
1787 let module = GraphNode::ExternalModule {
1788 name: "fastapi".to_string(),
1789 category: ModuleCategory::WebFramework,
1790 };
1791 assert_eq!(module.display_name(), "fastapi");
1792 }
1793
1794 #[test]
1795 fn graph_node_file_id() {
1796 let file = GraphNode::File {
1797 file_id: FileId(1),
1798 path: "test.py".to_string(),
1799 language: Language::Python,
1800 };
1801 assert_eq!(file.file_id(), Some(FileId(1)));
1802
1803 let module = GraphNode::ExternalModule {
1804 name: "requests".to_string(),
1805 category: ModuleCategory::HttpClient,
1806 };
1807 assert_eq!(module.file_id(), None);
1808 }
1809
1810 #[test]
1811 fn graph_node_is_file() {
1812 let file = GraphNode::File {
1813 file_id: FileId(1),
1814 path: "test.py".to_string(),
1815 language: Language::Python,
1816 };
1817 assert!(file.is_file());
1818
1819 let func = GraphNode::Function {
1820 file_id: FileId(1),
1821 name: "test".to_string(),
1822 qualified_name: "test".to_string(),
1823 is_async: false,
1824 is_handler: false,
1825 http_method: None,
1826 http_path: None,
1827 };
1828 assert!(!func.is_file());
1829 }
1830
1831 #[test]
1834 fn graph_edge_kind_debug() {
1835 let edge = GraphEdgeKind::Contains;
1836 let debug_str = format!("{:?}", edge);
1837 assert!(debug_str.contains("Contains"));
1838
1839 let edge = GraphEdgeKind::Imports;
1840 let debug_str = format!("{:?}", edge);
1841 assert!(debug_str.contains("Imports"));
1842
1843 let edge = GraphEdgeKind::ImportsFrom {
1844 items: vec!["FastAPI".to_string()],
1845 };
1846 let debug_str = format!("{:?}", edge);
1847 assert!(debug_str.contains("ImportsFrom"));
1848 assert!(debug_str.contains("FastAPI"));
1849
1850 let edge = GraphEdgeKind::UsesLibrary;
1851 let debug_str = format!("{:?}", edge);
1852 assert!(debug_str.contains("UsesLibrary"));
1853 }
1854
1855 #[test]
1856 fn graph_edge_kind_eq() {
1857 assert_eq!(GraphEdgeKind::Contains, GraphEdgeKind::Contains);
1858 assert_eq!(GraphEdgeKind::Imports, GraphEdgeKind::Imports);
1859 assert_ne!(GraphEdgeKind::Contains, GraphEdgeKind::Imports);
1860
1861 let edge1 = GraphEdgeKind::ImportsFrom {
1862 items: vec!["A".to_string()],
1863 };
1864 let edge2 = GraphEdgeKind::ImportsFrom {
1865 items: vec!["A".to_string()],
1866 };
1867 let edge3 = GraphEdgeKind::ImportsFrom {
1868 items: vec!["B".to_string()],
1869 };
1870 assert_eq!(edge1, edge2);
1871 assert_ne!(edge1, edge3);
1872 }
1873
1874 #[test]
1877 fn build_code_graph_empty_semantics() {
1878 let sem_entries: Vec<(FileId, Arc<SourceSemantics>)> = vec![];
1879 let cg = build_code_graph(&sem_entries);
1880 assert_eq!(cg.graph.node_count(), 0);
1881 assert_eq!(cg.graph.edge_count(), 0);
1882 }
1883
1884 #[test]
1885 fn build_code_graph_single_file_no_fastapi() {
1886 let (file_id, sem) = parse_and_build_semantics("test.py", "x = 1\ny = 2");
1887 let sem_entries = vec![(file_id, sem)];
1888
1889 let cg = build_code_graph(&sem_entries);
1890
1891 assert!(cg.graph.node_count() >= 1);
1893 assert!(cg.file_nodes.contains_key(&file_id));
1894 }
1895
1896 #[test]
1897 fn build_code_graph_with_function() {
1898 let src = r#"
1899def process_data(data):
1900 return data * 2
1901
1902async def fetch_user(user_id):
1903 return {"id": user_id}
1904"#;
1905 let (file_id, sem) = parse_and_build_semantics("handlers.py", src);
1906 let sem_entries = vec![(file_id, sem)];
1907
1908 let cg = build_code_graph(&sem_entries);
1909
1910 let stats = cg.stats();
1912 assert_eq!(stats.file_count, 1);
1913 assert!(stats.function_count >= 2);
1914
1915 assert!(
1917 cg.function_nodes
1918 .contains_key(&(file_id, "process_data".to_string()))
1919 );
1920 assert!(
1921 cg.function_nodes
1922 .contains_key(&(file_id, "fetch_user".to_string()))
1923 );
1924 }
1925
1926 #[test]
1927 fn build_code_graph_with_external_imports() {
1928 let src = r#"
1929import requests
1930from fastapi import FastAPI
1931import sqlalchemy
1932"#;
1933 let (file_id, sem) = parse_and_build_semantics("main.py", src);
1934 let sem_entries = vec![(file_id, sem)];
1935
1936 let cg = build_code_graph(&sem_entries);
1937
1938 assert!(cg.external_modules.contains_key("requests"));
1940 assert!(cg.external_modules.contains_key("fastapi"));
1941 assert!(cg.external_modules.contains_key("sqlalchemy"));
1942
1943 let stats = cg.stats();
1945 assert!(stats.uses_library_edge_count >= 3);
1946
1947 if let Some(&idx) = cg.external_modules.get("requests") {
1949 if let GraphNode::ExternalModule { category, .. } = &cg.graph[idx] {
1950 assert_eq!(*category, ModuleCategory::HttpClient);
1951 }
1952 }
1953 }
1954
1955 #[test]
1956 fn build_code_graph_with_fastapi_app() {
1957 let src = r#"
1958from fastapi import FastAPI
1959
1960app = FastAPI()
1961"#;
1962 let (file_id, sem) = parse_and_build_semantics("main.py", src);
1963 let sem_entries = vec![(file_id, sem)];
1964
1965 let cg = build_code_graph(&sem_entries);
1966
1967 assert!(cg.graph.node_count() >= 2);
1969
1970 assert!(cg.graph.edge_count() >= 1);
1972 }
1973
1974 #[test]
1975 fn build_code_graph_with_fastapi_middleware() {
1976 let src = r#"
1977from fastapi import FastAPI
1978from fastapi.middleware.cors import CORSMiddleware
1979
1980app = FastAPI()
1981
1982app.add_middleware(
1983 CORSMiddleware,
1984 allow_origins=["*"],
1985)
1986"#;
1987 let (file_id, sem) = parse_and_build_semantics("main.py", src);
1988 let sem_entries = vec![(file_id, sem)];
1989
1990 let cg = build_code_graph(&sem_entries);
1991
1992 assert!(cg.graph.node_count() >= 3);
1994
1995 assert!(cg.graph.edge_count() >= 3);
1997 }
1998
1999 #[test]
2000 fn build_code_graph_multiple_files() {
2001 let (file_id1, sem1) = parse_python_with_id("file1.py", "x = 1", 1);
2002 let (file_id2, sem2) = parse_python_with_id("file2.py", "y = 2", 2);
2003
2004 let sem_entries = vec![(file_id1, sem1), (file_id2, sem2)];
2005
2006 let cg = build_code_graph(&sem_entries);
2007
2008 assert_eq!(cg.stats().file_count, 2);
2010 assert!(cg.file_nodes.contains_key(&file_id1));
2011 assert!(cg.file_nodes.contains_key(&file_id2));
2012 }
2013
2014 #[test]
2015 fn build_code_graph_with_fastapi_routes() {
2016 let src = r#"
2017from fastapi import FastAPI
2018
2019app = FastAPI()
2020
2021@app.get("/users")
2022async def get_users():
2023 return []
2024
2025@app.post("/users")
2026async def create_user():
2027 return {}
2028"#;
2029 let (file_id, sem) = parse_and_build_semantics("routes.py", src);
2030 let sem_entries = vec![(file_id, sem)];
2031
2032 let cg = build_code_graph(&sem_entries);
2033
2034 assert!(cg.graph.node_count() >= 4);
2036
2037 let mut route_count = 0;
2039 for node in cg.graph.node_weights() {
2040 if matches!(node, GraphNode::FastApiRoute { .. }) {
2041 route_count += 1;
2042 }
2043 }
2044 assert_eq!(route_count, 2);
2045 }
2046
2047 #[test]
2048 fn build_code_graph_middleware_attached_to_correct_app() {
2049 let src = r#"
2050from fastapi import FastAPI
2051from fastapi.middleware.cors import CORSMiddleware
2052
2053app = FastAPI()
2054
2055app.add_middleware(CORSMiddleware)
2056"#;
2057 let (file_id, sem) = parse_and_build_semantics("main.py", src);
2058 let sem_entries = vec![(file_id, sem)];
2059
2060 let cg = build_code_graph(&sem_entries);
2061
2062 let mut found_middleware_edge = false;
2064 for edge in cg.graph.edge_weights() {
2065 if matches!(edge, GraphEdgeKind::FastApiAppHasMiddleware) {
2066 found_middleware_edge = true;
2067 break;
2068 }
2069 }
2070 assert!(found_middleware_edge);
2071 }
2072
2073 #[test]
2074 fn build_code_graph_middleware_without_matching_app() {
2075 let src = r#"
2076from fastapi.middleware.cors import CORSMiddleware
2077
2078def setup_cors(app):
2079 app.add_middleware(CORSMiddleware)
2080"#;
2081 let (file_id, sem) = parse_and_build_semantics("cors_setup.py", src);
2082 let sem_entries = vec![(file_id, sem)];
2083
2084 let cg = build_code_graph(&sem_entries);
2085
2086 assert!(cg.graph.node_count() >= 1);
2088 }
2089
2090 #[test]
2093 fn code_graph_get_external_dependencies() {
2094 let src = r#"
2095import requests
2096from fastapi import FastAPI
2097"#;
2098 let (file_id, sem) = parse_and_build_semantics("main.py", src);
2099 let sem_entries = vec![(file_id, sem)];
2100
2101 let cg = build_code_graph(&sem_entries);
2102
2103 let deps = cg.get_external_dependencies(file_id);
2104 assert!(deps.contains(&"requests".to_string()));
2105 assert!(deps.contains(&"fastapi".to_string()));
2106 }
2107
2108 #[test]
2109 fn code_graph_get_files_using_library() {
2110 let src = r#"import requests"#;
2111 let (file_id, sem) = parse_and_build_semantics("main.py", src);
2112 let sem_entries = vec![(file_id, sem)];
2113
2114 let cg = build_code_graph(&sem_entries);
2115
2116 let files = cg.get_files_using_library("requests");
2117 assert!(files.contains(&file_id));
2118 }
2119
2120 #[test]
2121 fn code_graph_stats() {
2122 let src = r#"
2123import requests
2124from fastapi import FastAPI
2125
2126app = FastAPI()
2127
2128def process():
2129 pass
2130"#;
2131 let (file_id, sem) = parse_and_build_semantics("main.py", src);
2132 let sem_entries = vec![(file_id, sem)];
2133
2134 let cg = build_code_graph(&sem_entries);
2135 let stats = cg.stats();
2136
2137 assert_eq!(stats.file_count, 1);
2138 assert!(stats.function_count >= 1);
2139 assert!(stats.external_module_count >= 2);
2140 assert!(stats.uses_library_edge_count >= 2);
2141 assert!(stats.contains_edge_count >= 1);
2142 }
2143
2144 #[test]
2147 fn categorize_module_http_client() {
2148 assert_eq!(categorize_module("requests"), ModuleCategory::HttpClient);
2149 assert_eq!(categorize_module("httpx"), ModuleCategory::HttpClient);
2150 assert_eq!(categorize_module("axios"), ModuleCategory::HttpClient);
2151 assert_eq!(categorize_module("reqwest"), ModuleCategory::HttpClient);
2152 }
2153
2154 #[test]
2155 fn categorize_module_database() {
2156 assert_eq!(categorize_module("sqlalchemy"), ModuleCategory::Database);
2157 assert_eq!(categorize_module("prisma"), ModuleCategory::Database);
2158 assert_eq!(categorize_module("diesel"), ModuleCategory::Database);
2159 }
2160
2161 #[test]
2162 fn categorize_module_web_framework() {
2163 assert_eq!(categorize_module("fastapi"), ModuleCategory::WebFramework);
2164 assert_eq!(categorize_module("express"), ModuleCategory::WebFramework);
2165 assert_eq!(categorize_module("gin"), ModuleCategory::WebFramework);
2166 }
2167
2168 #[test]
2169 fn categorize_module_async_runtime() {
2170 assert_eq!(categorize_module("asyncio"), ModuleCategory::AsyncRuntime);
2171 assert_eq!(categorize_module("tokio"), ModuleCategory::AsyncRuntime);
2172 }
2173
2174 #[test]
2175 fn categorize_module_logging() {
2176 assert_eq!(categorize_module("logging"), ModuleCategory::Logging);
2177 assert_eq!(categorize_module("structlog"), ModuleCategory::Logging);
2178 assert_eq!(categorize_module("tracing"), ModuleCategory::Logging);
2179 }
2180
2181 #[test]
2182 fn categorize_module_resilience() {
2183 assert_eq!(categorize_module("tenacity"), ModuleCategory::Resilience);
2184 assert_eq!(categorize_module("stamina"), ModuleCategory::Resilience);
2185 }
2186
2187 #[test]
2188 fn categorize_module_stdlib() {
2189 assert_eq!(categorize_module("os"), ModuleCategory::StandardLib);
2190 assert_eq!(categorize_module("json"), ModuleCategory::StandardLib);
2191 assert_eq!(categorize_module("typing"), ModuleCategory::StandardLib);
2192 }
2193
2194 #[test]
2195 fn categorize_module_other() {
2196 assert_eq!(categorize_module("some_random_lib"), ModuleCategory::Other);
2197 }
2198
2199 #[test]
2202 fn find_file_by_path_exact() {
2203 let (file_id, sem) = parse_and_build_semantics("src/main.py", "x = 1");
2204 let sem_entries = vec![(file_id, sem)];
2205
2206 let cg = build_code_graph(&sem_entries);
2207
2208 assert!(cg.find_file_by_path("src/main.py").is_some());
2209 assert!(cg.find_file_by_path("nonexistent.py").is_none());
2210 }
2211
2212 #[test]
2213 fn find_file_by_path_suffix() {
2214 let (file_id, sem) = parse_and_build_semantics("src/auth/middleware.py", "x = 1");
2215 let sem_entries = vec![(file_id, sem)];
2216
2217 let cg = build_code_graph(&sem_entries);
2218
2219 assert!(cg.find_file_by_path("auth/middleware.py").is_some());
2221 assert!(cg.find_file_by_path("middleware.py").is_some());
2222 }
2223
2224 #[test]
2227 fn calls_edge_count_starts_at_zero() {
2228 let src = r#"
2229def foo():
2230 pass
2231
2232def bar():
2233 pass
2234"#;
2235 let (file_id, sem) = parse_and_build_semantics("test.py", src);
2236 let sem_entries = vec![(file_id, sem)];
2237
2238 let cg = build_code_graph(&sem_entries);
2239 let stats = cg.stats();
2240
2241 assert_eq!(stats.calls_edge_count, 0);
2243 assert!(stats.function_count >= 2);
2245 }
2246
2247 #[test]
2248 fn calls_edge_manual_creation() {
2249 let mut cg = CodeGraph::new();
2251
2252 let file_id = FileId(1);
2253 let file_node = cg.graph.add_node(GraphNode::File {
2254 file_id,
2255 path: "test.py".to_string(),
2256 language: Language::Python,
2257 });
2258
2259 let func_a = cg.graph.add_node(GraphNode::Function {
2260 file_id,
2261 name: "func_a".to_string(),
2262 qualified_name: "func_a".to_string(),
2263 is_async: false,
2264 is_handler: false,
2265 http_method: None,
2266 http_path: None,
2267 });
2268
2269 let func_b = cg.graph.add_node(GraphNode::Function {
2270 file_id,
2271 name: "func_b".to_string(),
2272 qualified_name: "func_b".to_string(),
2273 is_async: false,
2274 is_handler: false,
2275 http_method: None,
2276 http_path: None,
2277 });
2278
2279 cg.graph
2281 .add_edge(file_node, func_a, GraphEdgeKind::Contains);
2282 cg.graph
2283 .add_edge(file_node, func_b, GraphEdgeKind::Contains);
2284
2285 cg.graph.add_edge(func_a, func_b, GraphEdgeKind::Calls);
2287
2288 let stats = cg.stats();
2289 assert_eq!(stats.calls_edge_count, 1);
2290 assert_eq!(stats.function_count, 2);
2291 assert_eq!(stats.contains_edge_count, 2);
2292 }
2293
2294 #[test]
2295 fn graph_edge_kind_calls_debug() {
2296 let edge = GraphEdgeKind::Calls;
2297 let debug_str = format!("{:?}", edge);
2298 assert!(debug_str.contains("Calls"));
2299 }
2300
2301 #[test]
2302 fn graph_edge_kind_calls_eq() {
2303 assert_eq!(GraphEdgeKind::Calls, GraphEdgeKind::Calls);
2304 assert_ne!(GraphEdgeKind::Calls, GraphEdgeKind::Contains);
2305 }
2306
2307 #[test]
2310 fn rebuild_indexes_restores_lookups() {
2311 let mut cg = CodeGraph::new();
2313
2314 let file_id = FileId(1);
2315 let file_node = cg.graph.add_node(GraphNode::File {
2316 file_id,
2317 path: "test.py".to_string(),
2318 language: Language::Python,
2319 });
2320 cg.file_nodes.insert(file_id, file_node);
2321 cg.path_to_file.insert("test.py".to_string(), file_node);
2322
2323 let func_node = cg.graph.add_node(GraphNode::Function {
2324 file_id,
2325 name: "my_func".to_string(),
2326 qualified_name: "my_func".to_string(),
2327 is_async: false,
2328 is_handler: false,
2329 http_method: None,
2330 http_path: None,
2331 });
2332 cg.function_nodes
2333 .insert((file_id, "my_func".to_string()), func_node);
2334
2335 let class_node = cg.graph.add_node(GraphNode::Class {
2336 file_id,
2337 name: "MyClass".to_string(),
2338 });
2339 cg.class_nodes
2340 .insert((file_id, "MyClass".to_string()), class_node);
2341
2342 let ext_node = cg.graph.add_node(GraphNode::ExternalModule {
2343 name: "requests".to_string(),
2344 category: ModuleCategory::HttpClient,
2345 });
2346 cg.external_modules.insert("requests".to_string(), ext_node);
2347
2348 cg.file_nodes.clear();
2350 cg.path_to_file.clear();
2351 cg.function_nodes.clear();
2352 cg.class_nodes.clear();
2353 cg.external_modules.clear();
2354
2355 assert!(cg.file_nodes.is_empty());
2357 assert!(cg.path_to_file.is_empty());
2358 assert!(cg.function_nodes.is_empty());
2359 assert!(cg.class_nodes.is_empty());
2360 assert!(cg.external_modules.is_empty());
2361
2362 cg.rebuild_indexes();
2364
2365 assert!(cg.file_nodes.contains_key(&file_id));
2367 assert!(cg.path_to_file.contains_key("test.py"));
2368 assert!(
2369 cg.function_nodes
2370 .contains_key(&(file_id, "my_func".to_string()))
2371 );
2372 assert!(
2373 cg.class_nodes
2374 .contains_key(&(file_id, "MyClass".to_string()))
2375 );
2376 assert!(cg.external_modules.contains_key("requests"));
2377 }
2378
2379 #[test]
2380 fn rebuild_indexes_clears_stale_data() {
2381 let mut cg = CodeGraph::new();
2382
2383 cg.file_nodes.insert(FileId(999), NodeIndex::new(0));
2385 cg.external_modules
2386 .insert("stale_module".to_string(), NodeIndex::new(0));
2387
2388 let file_id = FileId(1);
2390 let _file_node = cg.graph.add_node(GraphNode::File {
2391 file_id,
2392 path: "real.py".to_string(),
2393 language: Language::Python,
2394 });
2395
2396 cg.rebuild_indexes();
2398
2399 assert!(!cg.file_nodes.contains_key(&FileId(999)));
2401 assert!(!cg.external_modules.contains_key("stale_module"));
2402
2403 assert!(cg.file_nodes.contains_key(&file_id));
2405 assert!(cg.path_to_file.contains_key("real.py"));
2406 }
2407
2408 #[test]
2409 fn code_graph_serde_roundtrip() {
2410 let src = r#"
2412import requests
2413
2414def process():
2415 pass
2416"#;
2417 let (file_id, sem) = parse_and_build_semantics("main.py", src);
2418 let sem_entries = vec![(file_id, sem)];
2419 let cg = build_code_graph(&sem_entries);
2420
2421 let stats_before = cg.stats();
2423
2424 let json = serde_json::to_string(&cg).expect("serialization should succeed");
2426
2427 let mut cg_restored: CodeGraph =
2429 serde_json::from_str(&json).expect("deserialization should succeed");
2430
2431 assert!(cg_restored.file_nodes.is_empty());
2433 assert!(cg_restored.external_modules.is_empty());
2434
2435 cg_restored.rebuild_indexes();
2437
2438 let stats_after = cg_restored.stats();
2440 assert_eq!(stats_before.file_count, stats_after.file_count);
2441 assert_eq!(stats_before.function_count, stats_after.function_count);
2442 assert_eq!(
2443 stats_before.external_module_count,
2444 stats_after.external_module_count
2445 );
2446 assert_eq!(stats_before.total_nodes, stats_after.total_nodes);
2447 assert_eq!(stats_before.total_edges, stats_after.total_edges);
2448
2449 assert!(cg_restored.file_nodes.contains_key(&file_id));
2451 assert!(cg_restored.external_modules.contains_key("requests"));
2452 }
2453
2454 #[test]
2457 fn cross_file_call_edge_direct_import() {
2458 let helper_src = r#"
2460def helper_func():
2461 return 42
2462"#;
2463 let (helper_id, helper_sem) = parse_python_with_id("helpers.py", helper_src, 1);
2464
2465 let main_src = r#"
2467from helpers import helper_func
2468
2469def main():
2470 result = helper_func()
2471 return result
2472"#;
2473 let (main_id, main_sem) = parse_python_with_id("main.py", main_src, 2);
2474
2475 let sem_entries = vec![(helper_id, helper_sem), (main_id, main_sem)];
2476 let cg = build_code_graph(&sem_entries);
2477
2478 assert!(
2480 cg.function_nodes
2481 .contains_key(&(helper_id, "helper_func".to_string()))
2482 );
2483 assert!(
2484 cg.function_nodes
2485 .contains_key(&(main_id, "main".to_string()))
2486 );
2487
2488 let stats = cg.stats();
2490 assert!(stats.import_edge_count >= 1);
2491 }
2492
2493 #[test]
2494 fn find_import_source_file_returns_none_for_external() {
2495 let src = "x = 1";
2496 let (file_id, sem) = parse_and_build_semantics("test.py", src);
2497 let sem_entries = vec![(file_id, sem)];
2498 let cg = build_code_graph(&sem_entries);
2499
2500 assert!(find_import_source_file(&cg, "requests").is_none());
2502 assert!(find_import_source_file(&cg, "fastapi.FastAPI").is_none());
2503 }
2504
2505 #[test]
2506 fn find_import_source_file_finds_local_file() {
2507 let (file_id, sem) = parse_and_build_semantics("utils.py", "x = 1");
2508 let sem_entries = vec![(file_id, sem)];
2509 let cg = build_code_graph(&sem_entries);
2510
2511 assert!(find_import_source_file(&cg, "utils").is_some());
2513 }
2514
2515 use crate::parse::typescript::parse_typescript_file;
2518 use crate::semantics::typescript::model::TsFileSemantics;
2519
2520 fn parse_typescript_and_build_semantics(
2521 path: &str,
2522 source: &str,
2523 ) -> (FileId, Arc<SourceSemantics>) {
2524 let sf = SourceFile {
2525 path: path.to_string(),
2526 language: Language::Typescript,
2527 content: source.to_string(),
2528 };
2529 let file_id = FileId(1);
2530 let parsed = parse_typescript_file(file_id, &sf).expect("parsing should succeed");
2531 let mut sem = TsFileSemantics::from_parsed(&parsed);
2532 sem.analyze_frameworks(&parsed)
2533 .expect("framework analysis should succeed");
2534 (file_id, Arc::new(SourceSemantics::Typescript(sem)))
2535 }
2536
2537 #[test]
2538 fn build_code_graph_with_express_routes_with_http_metadata() {
2539 let src = r#"
2540import express from 'express';
2541
2542const app = express();
2543
2544async function getUsers(req, res) {
2545 res.json([]);
2546}
2547
2548function createUser(req, res) {
2549 res.json({});
2550}
2551
2552app.get('/users', getUsers);
2553app.post('/users', createUser);
2554"#;
2555 let (file_id, sem) = parse_typescript_and_build_semantics("app.ts", src);
2556 let sem_entries = vec![(file_id, sem)];
2557
2558 let cg = build_code_graph(&sem_entries);
2559
2560 let stats = cg.stats();
2562 assert_eq!(stats.file_count, 1);
2563 assert_eq!(stats.function_count, 2);
2564
2565 let get_users_key = (file_id, "getUsers".to_string());
2567 assert!(cg.function_nodes.contains_key(&get_users_key));
2568 let get_users_idx = cg.function_nodes[&get_users_key];
2569 if let GraphNode::Function {
2570 http_method,
2571 http_path,
2572 is_handler,
2573 ..
2574 } = &cg.graph[get_users_idx]
2575 {
2576 assert_eq!(*http_method, Some("GET".to_string()));
2577 assert_eq!(*http_path, Some("/users".to_string()));
2578 assert!(*is_handler);
2579 } else {
2580 panic!("Expected Function node for getUsers");
2581 }
2582
2583 let create_user_key = (file_id, "createUser".to_string());
2585 assert!(cg.function_nodes.contains_key(&create_user_key));
2586 let create_user_idx = cg.function_nodes[&create_user_key];
2587 if let GraphNode::Function {
2588 http_method,
2589 http_path,
2590 is_handler,
2591 ..
2592 } = &cg.graph[create_user_idx]
2593 {
2594 assert_eq!(*http_method, Some("POST".to_string()));
2595 assert_eq!(*http_path, Some("/users".to_string()));
2596 assert!(*is_handler);
2597 } else {
2598 panic!("Expected Function node for createUser");
2599 }
2600 }
2601
2602 #[test]
2605 fn resolve_relative_import_single_dot_same_dir() {
2606 let paths = resolve_relative_import("app.py", ".utils");
2608 assert!(paths.contains(&"utils.py".to_string()));
2609
2610 let paths = resolve_relative_import("pkg/app.py", ".utils");
2611 assert!(paths.contains(&"pkg/utils.py".to_string()));
2612 }
2613
2614 #[test]
2615 fn resolve_relative_import_double_dot_parent_dir() {
2616 let paths = resolve_relative_import("pkg/sub/app.py", "..utils");
2618 assert!(paths.contains(&"pkg/utils.py".to_string()));
2619 }
2620
2621 #[test]
2622 fn resolve_relative_import_triple_dot() {
2623 let paths = resolve_relative_import("a/b/c/app.py", "...utils");
2625 assert!(paths.contains(&"a/utils.py".to_string()));
2626 }
2627
2628 #[test]
2629 fn resolve_relative_import_nested_module() {
2630 let paths = resolve_relative_import("pkg/app.py", ".models.user");
2632 assert!(paths.contains(&"pkg/models/user.py".to_string()));
2633 }
2634
2635 #[test]
2636 fn resolve_relative_import_package_init() {
2637 let paths = resolve_relative_import("pkg/app.py", ".");
2639 assert!(paths.contains(&"pkg/__init__.py".to_string()));
2640 }
2641
2642 #[test]
2643 fn resolve_relative_import_root_file() {
2644 let paths = resolve_relative_import("app.py", ".utils");
2646 assert!(paths.contains(&"utils.py".to_string()));
2647 }
2648
2649 #[test]
2650 fn build_code_graph_with_relative_import() {
2651 let utils_src = r#"
2653def add(a, b):
2654 return a + b
2655"#;
2656 let (utils_id, utils_sem) = parse_python_with_id("utils.py", utils_src, 1);
2657
2658 let app_src = r#"
2660from .utils import add
2661
2662def main():
2663 return add(1, 2)
2664"#;
2665 let (app_id, app_sem) = parse_python_with_id("app.py", app_src, 2);
2666
2667 let sem_entries = vec![(utils_id, utils_sem), (app_id, app_sem)];
2668 let cg = build_code_graph(&sem_entries);
2669
2670 let stats = cg.stats();
2672 assert!(
2673 stats.import_edge_count >= 1,
2674 "Expected at least 1 import edge, got {}",
2675 stats.import_edge_count
2676 );
2677
2678 let app_file_idx = cg.file_nodes.get(&app_id).expect("app file should exist");
2680 let mut found_import_edge = false;
2681 for edge in cg.graph.edges(*app_file_idx) {
2682 if let GraphEdgeKind::ImportsFrom { items } = edge.weight() {
2683 if items.contains(&"add".to_string()) {
2684 found_import_edge = true;
2685 }
2686 }
2687 }
2688 assert!(
2689 found_import_edge,
2690 "Expected ImportsFrom edge with 'add' item"
2691 );
2692 }
2693
2694 #[test]
2695 fn build_code_graph_with_relative_import_nested() {
2696 let utils_src = r#"
2698def helper():
2699 pass
2700"#;
2701 let (utils_id, utils_sem) = parse_python_with_id("pkg/utils.py", utils_src, 1);
2702
2703 let app_src = r#"
2705from ..utils import helper
2706"#;
2707 let (app_id, app_sem) = parse_python_with_id("pkg/sub/app.py", app_src, 2);
2708
2709 let sem_entries = vec![(utils_id, utils_sem), (app_id, app_sem)];
2710 let cg = build_code_graph(&sem_entries);
2711
2712 let stats = cg.stats();
2714 assert!(
2715 stats.import_edge_count >= 1,
2716 "Expected at least 1 import edge for ..utils"
2717 );
2718 }
2719
2720 #[test]
2721 fn find_import_source_file_returns_none_for_relative() {
2722 let src = "x = 1";
2723 let (file_id, sem) = parse_and_build_semantics("test.py", src);
2724 let sem_entries = vec![(file_id, sem)];
2725 let cg = build_code_graph(&sem_entries);
2726
2727 assert!(find_import_source_file(&cg, ".utils").is_none());
2729 assert!(find_import_source_file(&cg, "..models").is_none());
2730 }
2731
2732 #[test]
2733 fn cross_file_call_edge_with_relative_import() {
2734 let utils_src = r#"
2739def add(a, b):
2740 return a + b
2741"#;
2742 let (utils_id, utils_sem) = parse_python_with_id("utils.py", utils_src, 1);
2743
2744 let app_src = r#"
2746from .utils import add
2747
2748def main():
2749 result = add(1, 2)
2750 return result
2751"#;
2752 let (app_id, app_sem) = parse_python_with_id("app.py", app_src, 2);
2753
2754 let sem_entries = vec![(utils_id, utils_sem), (app_id, app_sem)];
2755 let cg = build_code_graph(&sem_entries);
2756
2757 assert!(
2759 cg.function_nodes
2760 .contains_key(&(utils_id, "add".to_string()))
2761 );
2762 assert!(
2763 cg.function_nodes
2764 .contains_key(&(app_id, "main".to_string()))
2765 );
2766
2767 let stats = cg.stats();
2769 assert!(
2770 stats.calls_edge_count >= 1,
2771 "Expected at least 1 Calls edge for cross-file call via relative import, got {}",
2772 stats.calls_edge_count
2773 );
2774
2775 let main_func_idx = cg
2777 .function_nodes
2778 .get(&(app_id, "main".to_string()))
2779 .expect("main function should exist");
2780 let add_func_idx = cg
2781 .function_nodes
2782 .get(&(utils_id, "add".to_string()))
2783 .expect("add function should exist");
2784
2785 let mut found_calls_edge = false;
2786 for edge in cg.graph.edges(*main_func_idx) {
2787 if matches!(edge.weight(), GraphEdgeKind::Calls) {
2788 if edge.target() == *add_func_idx {
2789 found_calls_edge = true;
2790 break;
2791 }
2792 }
2793 }
2794 assert!(found_calls_edge, "Expected Calls edge from main() to add()");
2795 }
2796
2797 #[test]
2798 fn find_import_source_file_with_context_resolves_relative() {
2799 let (utils_id, utils_sem) = parse_python_with_id("utils.py", "x = 1", 1);
2801 let sem_entries = vec![(utils_id, utils_sem)];
2802 let cg = build_code_graph(&sem_entries);
2803
2804 assert!(find_import_source_file(&cg, ".utils").is_none());
2806
2807 let result = find_import_source_file_with_context(&cg, ".utils", "app.py");
2809 assert!(
2810 result.is_some(),
2811 "Expected to find utils.py via relative import from app.py"
2812 );
2813 }
2814}