Skip to main content

nusy_codegraph/
python_resolver.rs

1//! Python module resolver — maps dotted import paths to file paths.
2//!
3//! Builds a module index from a directory tree by walking `__init__.py`
4//! locations and file paths, then resolves imports (absolute and relative)
5//! to their corresponding source files.
6//!
7//! # Usage
8//!
9//! ```ignore
10//! let resolver = PythonModuleResolver::from_root(Path::new("_archive/brain-v13/brain/"))?;
11//!
12//! // Resolve an absolute import
13//! let path = resolver.resolve_import("brain.perception.signal_fusion", None);
14//! // → Some("_archive/brain-v13/brain/perception/signal_fusion.py")
15//!
16//! // Resolve a relative import
17//! let from_file = Path::new("brain/perception/assessors.py");
18//! let path = resolver.resolve_import(".utils", Some(from_file));
19//! // → Some("brain/perception/utils.py")
20//! ```
21
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25/// Errors from the Python module resolver.
26#[derive(Debug, thiserror::Error)]
27pub enum ResolverError {
28    #[error("IO error walking directory: {0}")]
29    Io(#[from] std::io::Error),
30}
31
32/// Resolves Python import statements to file paths.
33///
34/// Builds an index of module dotted names → file paths from a root directory.
35/// Handles absolute imports and relative imports (e.g., `from .utils import X`).
36pub struct PythonModuleResolver {
37    root: PathBuf,
38    /// Dotted module name → relative file path (from root).
39    module_to_file: HashMap<String, PathBuf>,
40}
41
42impl PythonModuleResolver {
43    /// Build a resolver from a Python package root directory.
44    ///
45    /// Walks the directory tree, discovering all `.py` files and building
46    /// the dotted-name → path index.
47    pub fn from_root(root: &Path) -> Result<Self, ResolverError> {
48        let mut module_to_file = HashMap::new();
49        build_module_index(root, root, &mut module_to_file)?;
50        Ok(Self {
51            root: root.to_path_buf(),
52            module_to_file,
53        })
54    }
55
56    /// Resolve an import statement to a file path, if possible.
57    ///
58    /// # Arguments
59    /// - `import_stmt`: the module name as it appears in source. For relative
60    ///   imports (starting with `.`), the `from_file` context is required.
61    ///   Examples: `"brain.perception.signal_fusion"`, `".utils"`, `"..models"`
62    /// - `from_file`: the file that contains the import (needed for relative resolution).
63    ///   Should be a path relative to `root`. If `None`, relative imports fail.
64    ///
65    /// # Returns
66    /// The absolute file path if resolvable, `None` otherwise.
67    pub fn resolve_import(&self, import_stmt: &str, from_file: Option<&Path>) -> Option<PathBuf> {
68        if import_stmt.starts_with('.') {
69            self.resolve_relative(import_stmt, from_file?)
70        } else {
71            self.resolve_absolute(import_stmt)
72        }
73    }
74
75    /// Number of modules in the index.
76    pub fn module_count(&self) -> usize {
77        self.module_to_file.len()
78    }
79
80    /// Whether the given dotted module name is known.
81    pub fn knows_module(&self, dotted: &str) -> bool {
82        self.module_to_file.contains_key(dotted)
83    }
84
85    /// Root directory this resolver was built from.
86    pub fn root(&self) -> &Path {
87        &self.root
88    }
89
90    // ─── Private resolution logic ────────────────────────────────────────────
91
92    fn resolve_absolute(&self, module: &str) -> Option<PathBuf> {
93        // Direct lookup in the index
94        if let Some(p) = self.module_to_file.get(module) {
95            return Some(self.root.join(p));
96        }
97
98        // Fallback: strip leading package component and try again
99        // (handles cases where root is the package directory, e.g., root = "brain/")
100        if let Some((_pkg, rest)) = module.split_once('.')
101            && let Some(p) = self.module_to_file.get(rest)
102        {
103            return Some(self.root.join(p));
104        }
105
106        None
107    }
108
109    fn resolve_relative(&self, import_stmt: &str, from_file: &Path) -> Option<PathBuf> {
110        // Count leading dots: "." → 1 level up, ".." → 2 levels up
111        let dots = import_stmt.chars().take_while(|c| *c == '.').count();
112        let rest = &import_stmt[dots..];
113
114        // `from_file` is relative to root (e.g., "perception/assessors.py")
115        // Move up `dots` levels from the containing directory
116        let mut base = from_file.parent()?;
117        for _ in 1..dots {
118            base = base.parent().unwrap_or(base);
119        }
120
121        let target_path = if rest.is_empty() {
122            // `from . import X` — the package __init__.py
123            base.join("__init__.py")
124        } else {
125            // `from .utils import X` → base/utils.py
126            let rel_path = rest.replace('.', "/");
127            let as_file = base.join(format!("{rel_path}.py"));
128            let as_pkg = base.join(&rel_path).join("__init__.py");
129
130            if as_file.exists() || self.module_to_file.values().any(|p| p == &as_file) {
131                as_file
132            } else {
133                as_pkg
134            }
135        };
136
137        // Convert to absolute
138        let absolute = self.root.join(&target_path);
139        if absolute.exists() {
140            Some(absolute)
141        } else {
142            None
143        }
144    }
145}
146
147// ─── Directory walking ────────────────────────────────────────────────────────
148
149fn build_module_index(
150    root: &Path,
151    dir: &Path,
152    index: &mut HashMap<String, PathBuf>,
153) -> Result<(), ResolverError> {
154    for entry in std::fs::read_dir(dir)? {
155        let entry = entry?;
156        let path = entry.path();
157
158        if path.is_dir() {
159            let name = path
160                .file_name()
161                .map(|n| n.to_string_lossy().to_string())
162                .unwrap_or_default();
163            // Skip non-package directories
164            if name.starts_with('.')
165                || name == "__pycache__"
166                || name == "node_modules"
167                || name == ".git"
168                || name == "venv"
169                || name == ".venv"
170            {
171                continue;
172            }
173            build_module_index(root, &path, index)?;
174        } else if path.extension().is_some_and(|ext| ext == "py") {
175            // Compute relative path and dotted module name
176            let rel = path.strip_prefix(root).unwrap_or(&path);
177            let dotted = path_to_dotted(rel);
178            index.insert(dotted, rel.to_path_buf());
179        }
180    }
181    Ok(())
182}
183
184/// Convert a relative file path to a dotted Python module name.
185///
186/// Examples:
187/// - `"brain/perception/signal_fusion.py"` → `"brain.perception.signal_fusion"`
188/// - `"__init__.py"` → `"__init__"`
189/// - `"brain/__init__.py"` → `"brain"`
190fn path_to_dotted(rel: &Path) -> String {
191    let without_ext = rel
192        .with_extension("")
193        .display()
194        .to_string()
195        .replace(['/', '\\'], ".");
196
197    // `brain/__init__` → `brain` (the package itself)
198    if without_ext.ends_with(".__init__") {
199        without_ext[..without_ext.len() - ".__init__".len()].to_string()
200    } else if without_ext == "__init__" {
201        String::new()
202    } else {
203        without_ext
204    }
205}
206
207// ─── Tests ───────────────────────────────────────────────────────────────────
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::fs;
213
214    fn make_pkg(dir: &Path, files: &[(&str, &str)]) {
215        for (rel, content) in files {
216            let path = dir.join(rel);
217            if let Some(parent) = path.parent() {
218                fs::create_dir_all(parent).expect("create dir");
219            }
220            fs::write(&path, content).expect("write file");
221        }
222    }
223
224    #[test]
225    fn test_resolver_indexes_py_files() {
226        let dir = tempfile::tempdir().expect("tempdir");
227        make_pkg(
228            dir.path(),
229            &[
230                ("brain/__init__.py", ""),
231                ("brain/perception/__init__.py", ""),
232                ("brain/perception/signal_fusion.py", "def fuse(): pass"),
233                ("brain/utils.py", "def helper(): pass"),
234            ],
235        );
236
237        let resolver = PythonModuleResolver::from_root(dir.path()).expect("build resolver");
238        assert!(resolver.module_count() >= 3, "should have >= 3 modules");
239
240        assert!(resolver.knows_module("brain.perception.signal_fusion"));
241        assert!(resolver.knows_module("brain.utils"));
242        assert!(resolver.knows_module("brain"));
243    }
244
245    #[test]
246    fn test_resolver_absolute_import() {
247        let dir = tempfile::tempdir().expect("tempdir");
248        make_pkg(
249            dir.path(),
250            &[("brain/__init__.py", ""), ("brain/signal.py", "")],
251        );
252
253        let resolver = PythonModuleResolver::from_root(dir.path()).expect("build resolver");
254        let resolved = resolver.resolve_import("brain.signal", None);
255        assert!(resolved.is_some(), "should resolve brain.signal");
256        assert!(
257            resolved.unwrap().ends_with("brain/signal.py"),
258            "should resolve to brain/signal.py"
259        );
260    }
261
262    #[test]
263    fn test_resolver_relative_import() {
264        let dir = tempfile::tempdir().expect("tempdir");
265        make_pkg(
266            dir.path(),
267            &[
268                ("perception/__init__.py", ""),
269                ("perception/signal_fusion.py", "from .utils import helper"),
270                ("perception/utils.py", "def helper(): pass"),
271            ],
272        );
273
274        let resolver = PythonModuleResolver::from_root(dir.path()).expect("build resolver");
275        let from_file = Path::new("perception/signal_fusion.py");
276        let resolved = resolver.resolve_import(".utils", Some(from_file));
277        assert!(resolved.is_some(), "should resolve .utils");
278        assert!(
279            resolved.unwrap().ends_with("perception/utils.py"),
280            "should resolve to perception/utils.py"
281        );
282    }
283
284    #[test]
285    fn test_resolver_unknown_import_returns_none() {
286        let dir = tempfile::tempdir().expect("tempdir");
287        make_pkg(dir.path(), &[("main.py", "")]);
288
289        let resolver = PythonModuleResolver::from_root(dir.path()).expect("build resolver");
290        let resolved = resolver.resolve_import("numpy", None);
291        assert!(
292            resolved.is_none(),
293            "external package 'numpy' should not resolve"
294        );
295    }
296
297    #[test]
298    fn test_resolver_skips_pycache() {
299        let dir = tempfile::tempdir().expect("tempdir");
300        make_pkg(
301            dir.path(),
302            &[("__pycache__/module.py", ""), ("real.py", "")],
303        );
304
305        let resolver = PythonModuleResolver::from_root(dir.path()).expect("build resolver");
306        assert!(
307            !resolver.knows_module("__pycache__.module"),
308            "should skip __pycache__"
309        );
310        assert!(resolver.knows_module("real"));
311    }
312
313    #[test]
314    fn test_path_to_dotted() {
315        assert_eq!(
316            path_to_dotted(Path::new("brain/perception/signal.py")),
317            "brain.perception.signal"
318        );
319        assert_eq!(path_to_dotted(Path::new("brain/__init__.py")), "brain");
320        assert_eq!(path_to_dotted(Path::new("utils.py")), "utils");
321    }
322}