nusy_codegraph/
python_resolver.rs1use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, thiserror::Error)]
27pub enum ResolverError {
28 #[error("IO error walking directory: {0}")]
29 Io(#[from] std::io::Error),
30}
31
32pub struct PythonModuleResolver {
37 root: PathBuf,
38 module_to_file: HashMap<String, PathBuf>,
40}
41
42impl PythonModuleResolver {
43 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 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 pub fn module_count(&self) -> usize {
77 self.module_to_file.len()
78 }
79
80 pub fn knows_module(&self, dotted: &str) -> bool {
82 self.module_to_file.contains_key(dotted)
83 }
84
85 pub fn root(&self) -> &Path {
87 &self.root
88 }
89
90 fn resolve_absolute(&self, module: &str) -> Option<PathBuf> {
93 if let Some(p) = self.module_to_file.get(module) {
95 return Some(self.root.join(p));
96 }
97
98 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 let dots = import_stmt.chars().take_while(|c| *c == '.').count();
112 let rest = &import_stmt[dots..];
113
114 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 base.join("__init__.py")
124 } else {
125 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 let absolute = self.root.join(&target_path);
139 if absolute.exists() {
140 Some(absolute)
141 } else {
142 None
143 }
144 }
145}
146
147fn 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 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 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
184fn path_to_dotted(rel: &Path) -> String {
191 let without_ext = rel
192 .with_extension("")
193 .display()
194 .to_string()
195 .replace(['/', '\\'], ".");
196
197 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#[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}