Skip to main content

portalis_transpiler/
dependency_graph.rs

1//! Dependency Graph - Directed graph of module dependencies
2//!
3//! This module provides:
4//! 1. Dependency graph construction from Python imports
5//! 2. Circular import detection using DFS
6//! 3. Dependency resolution and traversal
7//! 4. Import validation and optimization
8//!
9//! # Architecture
10//! - Uses petgraph for efficient graph operations
11//! - Nodes represent Python modules and files
12//! - Edges represent import relationships
13//! - Supports both internal (project) and external (library) dependencies
14
15use petgraph::graph::{DiGraph, NodeIndex};
16use petgraph::visit::DfsPostOrder;
17use petgraph::Direction;
18use serde::{Deserialize, Serialize};
19use std::collections::{HashMap, HashSet, VecDeque};
20
21use crate::import_analyzer::{ImportType, ImportedSymbol, PythonImport};
22use crate::stdlib_mapper::StdlibMapper;
23use crate::external_packages::ExternalPackageRegistry;
24
25/// Represents a node in the dependency graph
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
27pub struct ModuleNode {
28    /// Module/file identifier (e.g., "myapp.models", "numpy")
29    pub identifier: String,
30
31    /// Node type (file, package, or external)
32    pub node_type: NodeType,
33
34    /// File path for local modules
35    pub file_path: Option<String>,
36
37    /// Whether this is a local project module
38    pub is_local: bool,
39}
40
41/// Type of module node
42#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
43pub enum NodeType {
44    /// Python file (.py)
45    File,
46
47    /// Python package (directory with __init__.py)
48    Package,
49
50    /// External library (stdlib or third-party)
51    External,
52}
53
54/// Represents an edge (import relationship) in the graph
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ImportEdge {
57    /// Type of import (module, from, or star)
58    pub import_type: ImportType,
59
60    /// Specific items imported (for from imports) with their aliases
61    pub items: Vec<ImportedSymbol>,
62
63    /// Alias used (if any) for module-level imports
64    pub alias: Option<String>,
65
66    /// Line number where import occurs
67    pub line_number: Option<usize>,
68}
69
70/// Circular dependency cycle
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CircularDependency {
73    /// Modules involved in the cycle
74    pub cycle: Vec<String>,
75
76    /// Suggested fix
77    pub suggestion: String,
78}
79
80/// Import validation issue
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ValidationIssue {
83    /// Issue type
84    pub issue_type: IssueType,
85
86    /// Module/import with the issue
87    pub module: String,
88
89    /// Description of the issue
90    pub description: String,
91
92    /// Suggested fix
93    pub suggestion: Option<String>,
94
95    /// Severity level
96    pub severity: Severity,
97}
98
99/// Type of validation issue
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
101pub enum IssueType {
102    /// Unknown or unmapped module
103    UnknownModule,
104
105    /// Wildcard import (from x import *)
106    WildcardImport,
107
108    /// Unused import
109    UnusedImport,
110
111    /// Import conflict (same alias, different modules)
112    ImportConflict,
113
114    /// Relative import depth too deep
115    RelativeImportDepth,
116
117    /// Circular dependency
118    CircularDependency,
119}
120
121/// Severity level
122#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
123pub enum Severity {
124    Info,
125    Warning,
126    Error,
127}
128
129/// Optimization suggestion
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct OptimizationSuggestion {
132    /// Module being optimized
133    pub module: String,
134
135    /// Type of optimization
136    pub optimization_type: OptimizationType,
137
138    /// Description
139    pub description: String,
140
141    /// Suggested code change
142    pub suggested_code: String,
143}
144
145/// Type of optimization
146#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
147pub enum OptimizationType {
148    /// Remove unused import
149    RemoveUnused,
150
151    /// Combine imports from same module
152    CombineImports,
153
154    /// Replace star import with explicit imports
155    ReplaceStarImport,
156
157    /// Reorder imports (stdlib, external, local)
158    ReorderImports,
159}
160
161/// Dependency graph for module analysis
162pub struct DependencyGraph {
163    /// The directed graph
164    graph: DiGraph<ModuleNode, ImportEdge>,
165
166    /// Map from module identifier to node index
167    node_map: HashMap<String, NodeIndex>,
168
169    /// Stdlib mapper for resolution
170    stdlib_mapper: StdlibMapper,
171
172    /// External package registry
173    external_registry: ExternalPackageRegistry,
174
175    /// Tracked usage of imported symbols
176    #[allow(dead_code)]
177    symbol_usage: HashMap<String, HashSet<String>>,
178}
179
180impl DependencyGraph {
181    /// Create a new empty dependency graph
182    pub fn new() -> Self {
183        Self {
184            graph: DiGraph::new(),
185            node_map: HashMap::new(),
186            stdlib_mapper: StdlibMapper::new(),
187            external_registry: ExternalPackageRegistry::new(),
188            symbol_usage: HashMap::new(),
189        }
190    }
191
192    /// Add a module node to the graph
193    pub fn add_module(&mut self, identifier: String, node_type: NodeType, file_path: Option<String>) -> NodeIndex {
194        if let Some(&idx) = self.node_map.get(&identifier) {
195            return idx;
196        }
197
198        let is_local = matches!(node_type, NodeType::File | NodeType::Package);
199
200        let node = ModuleNode {
201            identifier: identifier.clone(),
202            node_type,
203            file_path,
204            is_local,
205        };
206
207        let idx = self.graph.add_node(node);
208        self.node_map.insert(identifier, idx);
209        idx
210    }
211
212    /// Add an import edge between modules
213    pub fn add_import(
214        &mut self,
215        from_module: &str,
216        to_module: &str,
217        import_type: ImportType,
218        items: Vec<ImportedSymbol>,
219        alias: Option<String>,
220        line_number: Option<usize>,
221    ) {
222        let from_idx = self.node_map.get(from_module).copied();
223        let to_idx = self.node_map.get(to_module).copied();
224
225        if let (Some(from), Some(to)) = (from_idx, to_idx) {
226            let edge = ImportEdge {
227                import_type,
228                items,
229                alias,
230                line_number,
231            };
232
233            self.graph.add_edge(from, to, edge);
234        }
235    }
236
237    /// Build graph from Python imports for a module
238    pub fn add_module_imports(&mut self, module_id: &str, file_path: Option<String>, imports: &[PythonImport]) {
239        // Add the source module
240        let source_idx = self.add_module(
241            module_id.to_string(),
242            NodeType::File,
243            file_path,
244        );
245
246        // Add all imported modules and edges
247        for import in imports {
248            // Determine node type based on whether it's mapped
249            let node_type = if self.stdlib_mapper.get_module(&import.module).is_some()
250                || self.external_registry.get_package(&import.module).is_some() {
251                NodeType::External
252            } else {
253                NodeType::Package
254            };
255
256            let target_idx = self.add_module(
257                import.module.clone(),
258                node_type,
259                None,
260            );
261
262            // Add the import edge
263            let edge = ImportEdge {
264                import_type: import.import_type,
265                items: import.items.clone(),
266                alias: import.alias.clone(),
267                line_number: None,
268            };
269
270            self.graph.add_edge(source_idx, target_idx, edge);
271        }
272    }
273
274    /// Detect circular dependencies using DFS
275    pub fn detect_circular_dependencies(&self) -> Vec<CircularDependency> {
276        let mut cycles = Vec::new();
277        let mut visited = HashSet::new();
278        let mut rec_stack = Vec::new();
279
280        // Check each node as a potential cycle start
281        for node_idx in self.graph.node_indices() {
282            if !visited.contains(&node_idx) {
283                self.dfs_detect_cycle(node_idx, &mut visited, &mut rec_stack, &mut cycles);
284            }
285        }
286
287        cycles
288    }
289
290    /// DFS helper for cycle detection
291    fn dfs_detect_cycle(
292        &self,
293        node: NodeIndex,
294        visited: &mut HashSet<NodeIndex>,
295        rec_stack: &mut Vec<NodeIndex>,
296        cycles: &mut Vec<CircularDependency>,
297    ) {
298        visited.insert(node);
299        rec_stack.push(node);
300
301        // Check all neighbors
302        for neighbor in self.graph.neighbors_directed(node, Direction::Outgoing) {
303            if !visited.contains(&neighbor) {
304                self.dfs_detect_cycle(neighbor, visited, rec_stack, cycles);
305            } else if rec_stack.contains(&neighbor) {
306                // Found a cycle - extract it
307                let cycle_start_pos = rec_stack.iter().position(|&n| n == neighbor).unwrap();
308                let cycle_nodes: Vec<String> = rec_stack[cycle_start_pos..]
309                    .iter()
310                    .map(|&idx| self.graph[idx].identifier.clone())
311                    .collect();
312
313                let suggestion = self.suggest_cycle_fix(&cycle_nodes);
314
315                cycles.push(CircularDependency {
316                    cycle: cycle_nodes,
317                    suggestion,
318                });
319            }
320        }
321
322        rec_stack.pop();
323    }
324
325    /// Suggest a fix for a circular dependency
326    fn suggest_cycle_fix(&self, cycle: &[String]) -> String {
327        if cycle.len() == 2 {
328            format!(
329                "Move shared code to a new module, or use delayed imports (import inside functions) in {}",
330                cycle[0]
331            )
332        } else {
333            format!(
334                "Refactor to break the cycle: {} → ... → {}. Consider:\n\
335                 1. Extract shared code to a new module\n\
336                 2. Use dependency injection\n\
337                 3. Move imports inside functions (delayed imports)",
338                cycle.first().unwrap(),
339                cycle.last().unwrap()
340            )
341        }
342    }
343
344    /// Validate imports and detect issues
345    pub fn validate_imports(&self, used_symbols: &HashMap<String, HashSet<String>>) -> Vec<ValidationIssue> {
346        let mut issues = Vec::new();
347
348        // Track wildcard imports
349        for edge_idx in self.graph.edge_indices() {
350            let (_, target_idx) = self.graph.edge_endpoints(edge_idx).unwrap();
351            let edge = &self.graph[edge_idx];
352            let target_node = &self.graph[target_idx];
353
354            // Check for wildcard imports
355            if edge.import_type == ImportType::StarImport {
356                issues.push(ValidationIssue {
357                    issue_type: IssueType::WildcardImport,
358                    module: target_node.identifier.clone(),
359                    description: format!(
360                        "Wildcard import 'from {} import *' should be avoided",
361                        target_node.identifier
362                    ),
363                    suggestion: Some("Use explicit imports instead".to_string()),
364                    severity: Severity::Warning,
365                });
366            }
367
368            // Check for unused imports
369            if edge.import_type == ImportType::FromImport && !edge.items.is_empty() {
370                let module_used = used_symbols.get(&target_node.identifier);
371
372                for item in &edge.items {
373                    if let Some(used) = module_used {
374                        if !used.contains(&item.name) {
375                            issues.push(ValidationIssue {
376                                issue_type: IssueType::UnusedImport,
377                                module: target_node.identifier.clone(),
378                                description: format!(
379                                    "Imported symbol '{}' from '{}' is not used",
380                                    item.name, target_node.identifier
381                                ),
382                                suggestion: Some(format!("Remove unused import: {}", item.name)),
383                                severity: Severity::Info,
384                            });
385                        }
386                    }
387                }
388            }
389        }
390
391        // Check for unknown/unmapped modules
392        for node_idx in self.graph.node_indices() {
393            let node = &self.graph[node_idx];
394
395            // Check Package nodes (potential external imports) that aren't locally defined
396            // External nodes are already mapped, so we skip those
397            if matches!(node.node_type, NodeType::Package) && node.file_path.is_none() {
398                // Check if it's mapped to stdlib or external package
399                if self.stdlib_mapper.get_module(&node.identifier).is_none()
400                    && self.external_registry.get_package(&node.identifier).is_none()
401                {
402                    issues.push(ValidationIssue {
403                        issue_type: IssueType::UnknownModule,
404                        module: node.identifier.clone(),
405                        description: format!(
406                            "Module '{}' is not mapped to a Rust crate",
407                            node.identifier
408                        ),
409                        suggestion: Some("Add mapping to stdlib_mapper or external_packages".to_string()),
410                        severity: Severity::Warning,
411                    });
412                }
413            }
414        }
415
416        // Check for import conflicts (same alias, different modules)
417        let mut alias_map: HashMap<String, Vec<String>> = HashMap::new();
418
419        for edge_idx in self.graph.edge_indices() {
420            let (_, target_idx) = self.graph.edge_endpoints(edge_idx).unwrap();
421            let edge = &self.graph[edge_idx];
422            let target_node = &self.graph[target_idx];
423
424            if let Some(ref alias) = edge.alias {
425                alias_map.entry(alias.clone())
426                    .or_default()
427                    .push(target_node.identifier.clone());
428            }
429        }
430
431        for (alias, modules) in alias_map {
432            if modules.len() > 1 {
433                issues.push(ValidationIssue {
434                    issue_type: IssueType::ImportConflict,
435                    module: modules.join(", "),
436                    description: format!(
437                        "Alias '{}' is used for multiple modules: {}",
438                        alias,
439                        modules.join(", ")
440                    ),
441                    suggestion: Some("Use different aliases for different modules".to_string()),
442                    severity: Severity::Error,
443                });
444            }
445        }
446
447        issues
448    }
449
450    /// Generate optimization suggestions
451    pub fn generate_optimizations(&self) -> Vec<OptimizationSuggestion> {
452        let mut suggestions = Vec::new();
453
454        // Group imports by module
455        let mut module_imports: HashMap<String, Vec<(NodeIndex, &ImportEdge)>> = HashMap::new();
456
457        for edge_idx in self.graph.edge_indices() {
458            let (source, target) = self.graph.edge_endpoints(edge_idx).unwrap();
459            let edge = &self.graph[edge_idx];
460            let target_module = &self.graph[target].identifier;
461
462            module_imports.entry(target_module.clone())
463                .or_default()
464                .push((source, edge));
465        }
466
467        // Suggest combining imports from same module
468        for (module, imports) in module_imports {
469            if imports.len() > 1 {
470                // Check if they're all from the same source
471                let sources: HashSet<_> = imports.iter().map(|(src, _)| *src).collect();
472
473                if sources.len() == 1 {
474                    let all_items: Vec<String> = imports.iter()
475                        .flat_map(|(_, edge)| edge.items.iter().map(|s| s.name.clone()))
476                        .collect();
477
478                    if !all_items.is_empty() {
479                        suggestions.push(OptimizationSuggestion {
480                            module: module.clone(),
481                            optimization_type: OptimizationType::CombineImports,
482                            description: format!(
483                                "Multiple imports from '{}' can be combined",
484                                module
485                            ),
486                            suggested_code: format!(
487                                "from {} import {}",
488                                module,
489                                all_items.join(", ")
490                            ),
491                        });
492                    }
493                }
494            }
495        }
496
497        // Suggest replacing star imports
498        for edge_idx in self.graph.edge_indices() {
499            let (_, target_idx) = self.graph.edge_endpoints(edge_idx).unwrap();
500            let edge = &self.graph[edge_idx];
501            let target = &self.graph[target_idx];
502
503            if edge.import_type == ImportType::StarImport {
504                suggestions.push(OptimizationSuggestion {
505                    module: target.identifier.clone(),
506                    optimization_type: OptimizationType::ReplaceStarImport,
507                    description: format!(
508                        "Replace 'from {} import *' with explicit imports",
509                        target.identifier
510                    ),
511                    suggested_code: format!(
512                        "from {} import <specific_items>",
513                        target.identifier
514                    ),
515                });
516            }
517        }
518
519        suggestions
520    }
521
522    /// Get all dependencies of a module (transitive)
523    pub fn get_dependencies(&self, module: &str) -> HashSet<String> {
524        let mut deps = HashSet::new();
525
526        if let Some(&node_idx) = self.node_map.get(module) {
527            let mut queue = VecDeque::new();
528            queue.push_back(node_idx);
529
530            while let Some(idx) = queue.pop_front() {
531                for neighbor in self.graph.neighbors_directed(idx, Direction::Outgoing) {
532                    let dep_module = &self.graph[neighbor].identifier;
533
534                    if deps.insert(dep_module.clone()) {
535                        queue.push_back(neighbor);
536                    }
537                }
538            }
539        }
540
541        deps
542    }
543
544    /// Get all dependents of a module (who imports this module)
545    pub fn get_dependents(&self, module: &str) -> HashSet<String> {
546        let mut dependents = HashSet::new();
547
548        if let Some(&node_idx) = self.node_map.get(module) {
549            for neighbor in self.graph.neighbors_directed(node_idx, Direction::Incoming) {
550                dependents.insert(self.graph[neighbor].identifier.clone());
551            }
552        }
553
554        dependents
555    }
556
557    /// Get topological sort of modules (build order)
558    pub fn topological_sort(&self) -> Result<Vec<String>, String> {
559        let mut post_order = DfsPostOrder::new(&self.graph, self.graph.node_indices().next().unwrap_or(NodeIndex::new(0)));
560        let mut sorted = Vec::new();
561
562        while let Some(node) = post_order.next(&self.graph) {
563            sorted.push(self.graph[node].identifier.clone());
564        }
565
566        sorted.reverse();
567        Ok(sorted)
568    }
569
570    /// Get graph statistics
571    pub fn stats(&self) -> GraphStats {
572        let total_modules = self.graph.node_count();
573        let total_imports = self.graph.edge_count();
574
575        let local_modules = self.graph.node_weights()
576            .filter(|n| n.is_local)
577            .count();
578
579        let external_modules = total_modules - local_modules;
580
581        GraphStats {
582            total_modules,
583            local_modules,
584            external_modules,
585            total_imports,
586        }
587    }
588}
589
590impl Default for DependencyGraph {
591    fn default() -> Self {
592        Self::new()
593    }
594}
595
596/// Graph statistics
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct GraphStats {
599    pub total_modules: usize,
600    pub local_modules: usize,
601    pub external_modules: usize,
602    pub total_imports: usize,
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn test_add_module() {
611        let mut graph = DependencyGraph::new();
612
613        let idx1 = graph.add_module("myapp.models".to_string(), NodeType::File, None);
614        let idx2 = graph.add_module("myapp.views".to_string(), NodeType::File, None);
615
616        assert_ne!(idx1, idx2);
617        assert_eq!(graph.graph.node_count(), 2);
618    }
619
620    #[test]
621    fn test_add_import() {
622        let mut graph = DependencyGraph::new();
623
624        graph.add_module("myapp.models".to_string(), NodeType::File, None);
625        graph.add_module("myapp.views".to_string(), NodeType::File, None);
626
627        graph.add_import(
628            "myapp.views",
629            "myapp.models",
630            ImportType::FromImport,
631            vec![ImportedSymbol { name: "User".to_string(), alias: None }],
632            None,
633            Some(1),
634        );
635
636        assert_eq!(graph.graph.edge_count(), 1);
637    }
638
639    #[test]
640    fn test_circular_dependency_detection() {
641        let mut graph = DependencyGraph::new();
642
643        // Create a circular dependency: A -> B -> C -> A
644        graph.add_module("module_a".to_string(), NodeType::File, None);
645        graph.add_module("module_b".to_string(), NodeType::File, None);
646        graph.add_module("module_c".to_string(), NodeType::File, None);
647
648        graph.add_import("module_a", "module_b", ImportType::Module, vec![], None, None);
649        graph.add_import("module_b", "module_c", ImportType::Module, vec![], None, None);
650        graph.add_import("module_c", "module_a", ImportType::Module, vec![], None, None);
651
652        let cycles = graph.detect_circular_dependencies();
653
654        assert!(!cycles.is_empty(), "Should detect circular dependency");
655        assert!(cycles[0].cycle.contains(&"module_a".to_string()));
656        assert!(cycles[0].cycle.contains(&"module_b".to_string()));
657    }
658
659    #[test]
660    fn test_validate_wildcard_import() {
661        let mut graph = DependencyGraph::new();
662
663        graph.add_module("main".to_string(), NodeType::File, None);
664        graph.add_module("utils".to_string(), NodeType::Package, None);
665
666        graph.add_import(
667            "main",
668            "utils",
669            ImportType::StarImport,
670            vec![],
671            None,
672            None,
673        );
674
675        let used_symbols = HashMap::new();
676        let issues = graph.validate_imports(&used_symbols);
677
678        assert!(!issues.is_empty());
679        assert!(issues.iter().any(|i| i.issue_type == IssueType::WildcardImport));
680    }
681
682    #[test]
683    fn test_get_dependencies() {
684        let mut graph = DependencyGraph::new();
685
686        graph.add_module("app".to_string(), NodeType::File, None);
687        graph.add_module("models".to_string(), NodeType::File, None);
688        graph.add_module("database".to_string(), NodeType::File, None);
689
690        graph.add_import("app", "models", ImportType::Module, vec![], None, None);
691        graph.add_import("models", "database", ImportType::Module, vec![], None, None);
692
693        let deps = graph.get_dependencies("app");
694
695        assert!(deps.contains("models"));
696        assert!(deps.contains("database"));
697    }
698
699    #[test]
700    fn test_optimization_suggestions() {
701        let mut graph = DependencyGraph::new();
702
703        graph.add_module("main".to_string(), NodeType::File, None);
704        graph.add_module("utils".to_string(), NodeType::Package, None);
705
706        // Add star import
707        graph.add_import("main", "utils", ImportType::StarImport, vec![], None, None);
708
709        let suggestions = graph.generate_optimizations();
710
711        assert!(!suggestions.is_empty());
712        assert!(suggestions.iter().any(|s| s.optimization_type == OptimizationType::ReplaceStarImport));
713    }
714}