Skip to main content

pecto_python/
dependencies.rs

1use crate::context::AnalysisContext;
2use crate::extractors::common::*;
3use pecto_core::model::*;
4
5/// Resolve dependencies between Python capabilities by analyzing import statements.
6pub fn resolve_dependencies(spec: &mut ProjectSpec, ctx: &AnalysisContext) {
7    for file in &ctx.files {
8        let root = file.tree.root_node();
9        let source = file.source.as_bytes();
10
11        // Find which capability this file belongs to
12        let Some(from_cap) = spec
13            .capabilities
14            .iter()
15            .find(|c| c.source == file.path)
16            .map(|c| c.name.clone())
17        else {
18            continue;
19        };
20
21        // Extract import statements
22        for i in 0..root.named_child_count() {
23            let node = root.named_child(i).unwrap();
24
25            match node.kind() {
26                // from app.models import Item, ItemCreate
27                "import_from_statement" => {
28                    let module = node
29                        .child_by_field_name("module_name")
30                        .map(|m| node_text(&m, source))
31                        .unwrap_or_default();
32
33                    if module.is_empty() {
34                        continue;
35                    }
36
37                    // Try to match the module path to a capability
38                    if let Some(to_cap) =
39                        resolve_module_to_capability(&module, &from_cap, &spec.capabilities)
40                    {
41                        // Extract imported names for reference
42                        let mut imported_names = Vec::new();
43                        for j in 0..node.named_child_count() {
44                            let child = node.named_child(j).unwrap();
45                            if child.kind() == "dotted_name" || child.kind() == "aliased_import" {
46                                imported_names.push(node_text(&child, source));
47                            }
48                        }
49
50                        let reference = format!(
51                            "from {} import {}",
52                            module,
53                            if imported_names.is_empty() {
54                                "*".to_string()
55                            } else {
56                                imported_names.join(", ")
57                            }
58                        );
59
60                        add_dependency_edge(
61                            &mut spec.dependencies,
62                            &from_cap,
63                            &to_cap,
64                            infer_dependency_kind(&to_cap, &module),
65                            reference,
66                        );
67                    }
68                }
69                // import app.models
70                "import_statement" => {
71                    let text = node_text(&node, source);
72                    let module = text.trim_start_matches("import ").trim();
73
74                    if let Some(to_cap) =
75                        resolve_module_to_capability(module, &from_cap, &spec.capabilities)
76                    {
77                        add_dependency_edge(
78                            &mut spec.dependencies,
79                            &from_cap,
80                            &to_cap,
81                            infer_dependency_kind(&to_cap, module),
82                            text,
83                        );
84                    }
85                }
86                _ => {}
87            }
88        }
89    }
90}
91
92/// Resolve a Python module path to a capability name.
93/// e.g., "app.api.deps" or "app.models" → matching capability
94fn resolve_module_to_capability(
95    module: &str,
96    from_cap: &str,
97    capabilities: &[Capability],
98) -> Option<String> {
99    // Convert module path to file path patterns:
100    // "app.models" → "app/models.py" or "app/models/__init__.py"
101    let module_as_path = module.replace('.', "/");
102
103    for cap in capabilities {
104        if cap.name == from_cap {
105            continue; // Skip self-references
106        }
107
108        // Check if capability source matches the module path
109        // e.g., source="backend/app/api/routes/items.py" matches "app.api.routes.items"
110        let source_no_ext = cap.source.trim_end_matches(".py");
111
112        if source_no_ext.ends_with(&module_as_path)
113            || source_no_ext.contains(&module_as_path)
114            || module_path_matches_source(module, &cap.source)
115        {
116            return Some(cap.name.clone());
117        }
118    }
119
120    // Try matching by capability name against module segments
121    // e.g., module "app.models" with capability "private-model" (source contains "models")
122    let module_segments: Vec<&str> = module.split('.').collect();
123    let last_segment = module_segments.last().unwrap_or(&"");
124
125    for cap in capabilities {
126        if cap.name == from_cap {
127            continue;
128        }
129
130        // Match last module segment against capability name or source
131        let cap_source_segments: Vec<&str> = cap.source.split('/').collect();
132        let cap_file = cap_source_segments
133            .last()
134            .unwrap_or(&"")
135            .trim_end_matches(".py");
136
137        if cap_file == *last_segment || cap.name == *last_segment || cap.name.contains(last_segment)
138        {
139            return Some(cap.name.clone());
140        }
141    }
142
143    None
144}
145
146/// Check if a module path matches a source file path.
147fn module_path_matches_source(module: &str, source: &str) -> bool {
148    let source_parts: Vec<&str> = source.trim_end_matches(".py").split('/').collect();
149    let module_parts: Vec<&str> = module.split('.').collect();
150
151    if module_parts.len() > source_parts.len() {
152        return false;
153    }
154
155    // Check if module parts match the tail of source parts
156    let offset = source_parts.len() - module_parts.len();
157    module_parts
158        .iter()
159        .zip(source_parts[offset..].iter())
160        .all(|(m, s)| m == s)
161}
162
163fn infer_dependency_kind(target: &str, module: &str) -> DependencyKind {
164    if target.contains("model") || module.contains("model") {
165        DependencyKind::Queries
166    } else if target.contains("event") || module.contains("event") || module.contains("task") {
167        DependencyKind::Listens
168    } else {
169        DependencyKind::Calls
170    }
171}
172
173fn add_dependency_edge(
174    deps: &mut Vec<DependencyEdge>,
175    from: &str,
176    to: &str,
177    kind: DependencyKind,
178    reference: String,
179) {
180    if let Some(existing) = deps.iter_mut().find(|d| d.from == from && d.to == to) {
181        if !existing.references.contains(&reference) {
182            existing.references.push(reference);
183        }
184    } else {
185        deps.push(DependencyEdge {
186            from: from.to_string(),
187            to: to.to_string(),
188            kind,
189            references: vec![reference],
190        });
191    }
192}