Skip to main content

mati_core/analysis/resolvers/
python.rs

1//! Python import resolver.
2//!
3//! Resolves dotted module paths to `.py` files or `__init__.py` packages.
4//! Relative imports (`.foo`, `..foo`) resolve from the importing file's
5//! directory. Absolute imports resolve from the repo root.
6//!
7//! Limitation: cannot distinguish stdlib from local modules without a venv
8//! scan. All imports are treated as potentially resolvable. False negatives
9//! (unresolved count) are acceptable at Layer 0.
10
11use std::path::Path;
12
13use super::{FileIndex, LanguageResolver};
14use crate::analysis::parser::ImportStatement;
15use crate::analysis::walker::Language;
16
17/// Python import resolver for dotted module paths and relative imports.
18pub struct PythonResolver;
19
20impl LanguageResolver for PythonResolver {
21    fn resolve(
22        &self,
23        import: &ImportStatement,
24        importing_file: &str,
25        file_index: &FileIndex,
26    ) -> Option<String> {
27        resolve_python(&import.path, importing_file, file_index)
28    }
29
30    fn language(&self) -> Language {
31        Language::Python
32    }
33
34    fn name(&self) -> &'static str {
35        "python"
36    }
37}
38
39/// Core resolution logic, extracted for direct testing.
40fn resolve_python(
41    import_path: &str,
42    importing_file: &str,
43    file_index: &FileIndex,
44) -> Option<String> {
45    let (base_dir, module_path) = if let Some(stripped) = import_path.strip_prefix('.') {
46        // Relative import: resolve from importing file's parent.
47        let parent = Path::new(importing_file)
48            .parent()
49            .map(|p| p.to_string_lossy().into_owned())
50            .unwrap_or_default();
51
52        // Handle `..module` (double-dot = go up one more level)
53        if let Some(double_stripped) = stripped.strip_prefix('.') {
54            let grandparent = Path::new(&parent)
55                .parent()
56                .map(|p| p.to_string_lossy().into_owned())
57                .unwrap_or_default();
58            (grandparent, double_stripped)
59        } else {
60            (parent, stripped)
61        }
62    } else {
63        (String::new(), import_path)
64    };
65
66    // Convert dots to slashes: foo.bar → foo/bar
67    let rel = module_path.replace('.', "/");
68
69    let prefix = if base_dir.is_empty() {
70        rel
71    } else {
72        format!("{base_dir}/{rel}")
73    };
74
75    // Try direct file: foo/bar.py
76    let py_file = format!("{prefix}.py");
77    if file_index.contains(&py_file) {
78        return Some(py_file);
79    }
80
81    // Try package: foo/bar/__init__.py
82    let init_file = format!("{prefix}/__init__.py");
83    if file_index.contains(&init_file) {
84        return Some(init_file);
85    }
86
87    None
88}
89
90// ── Tests ───────────────────────────────────────────────────────────────────
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::analysis::parser::import::ImportKind;
96
97    fn idx(paths: &[&str]) -> FileIndex {
98        FileIndex::new(paths.iter().map(|s| s.to_string()))
99    }
100
101    fn import(path: &str, kind: ImportKind) -> ImportStatement {
102        ImportStatement::new(path, kind, 1)
103    }
104
105    #[test]
106    fn absolute_import_resolves() {
107        let file_index = idx(&["app/main.py", "app/utils.py"]);
108        let resolver = PythonResolver;
109        let result = resolver.resolve(
110            &import("app.utils", ImportKind::Normal),
111            "app/main.py",
112            &file_index,
113        );
114        assert_eq!(result, Some("app/utils.py".into()));
115    }
116
117    #[test]
118    fn relative_import_resolves() {
119        let file_index = idx(&["app/main.py", "app/helpers.py"]);
120        let resolver = PythonResolver;
121        let result = resolver.resolve(
122            &import(".helpers", ImportKind::Relative),
123            "app/main.py",
124            &file_index,
125        );
126        assert_eq!(result, Some("app/helpers.py".into()));
127    }
128
129    #[test]
130    fn package_init_resolves() {
131        let file_index = idx(&["main.py", "pkg/__init__.py"]);
132        let resolver = PythonResolver;
133        let result = resolver.resolve(&import("pkg", ImportKind::Normal), "main.py", &file_index);
134        assert_eq!(result, Some("pkg/__init__.py".into()));
135    }
136
137    #[test]
138    fn double_dot_relative() {
139        let file_index = idx(&["app/sub/deep.py", "app/utils.py"]);
140        let resolver = PythonResolver;
141        let result = resolver.resolve(
142            &import("..utils", ImportKind::Relative),
143            "app/sub/deep.py",
144            &file_index,
145        );
146        assert_eq!(result, Some("app/utils.py".into()));
147    }
148
149    #[test]
150    fn unresolvable_returns_none() {
151        let file_index = idx(&["app/main.py"]);
152        let resolver = PythonResolver;
153        let result = resolver.resolve(
154            &import("app.nonexistent", ImportKind::Normal),
155            "app/main.py",
156            &file_index,
157        );
158        assert_eq!(result, None);
159    }
160}