Skip to main content

meta_ast/language/
import_resolver.rs

1//! Stateful import path resolution seam.
2//!
3//! Provides the `ImportResolver` trait and a `StatelessResolver` adapter
4//! that wraps existing stateless function pointers, allowing gradual
5//! migration to stateful per-language resolvers.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::{OnceLock, RwLock};
10
11/// Stateful import path resolution seam.
12///
13/// Implementors resolve a raw import string to an on-disk path within a project.
14/// The interface is stateful (takes `&self`) to allow implementors to cache
15/// config file reads (tsconfig.json, go.mod, sys.path) on first use.
16pub trait ImportResolver: Send + Sync {
17    fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf>;
18}
19
20/// Zero-cost adapter wrapping a stateless function pointer.
21///
22/// Bridges the existing `LanguageSpec.import_path_resolver` fn pointers
23/// to the `ImportResolver` trait without changing the `LanguageSpec` struct.
24pub struct StatelessResolver {
25    f: fn(&str, &Path, &Path) -> Option<PathBuf>,
26}
27
28impl StatelessResolver {
29    pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
30        Self { f }
31    }
32}
33
34impl ImportResolver for StatelessResolver {
35    fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
36        (self.f)(raw, source_dir, project_root)
37    }
38}
39
40/// Stateful resolver for Python import paths.
41pub struct PythonResolver {
42    f: fn(&str, &Path, &Path) -> Option<PathBuf>,
43    exists_cache: RwLock<HashMap<PathBuf, bool>>,
44}
45
46impl PythonResolver {
47    pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
48        Self {
49            f,
50            exists_cache: RwLock::new(HashMap::new()),
51        }
52    }
53}
54
55impl ImportResolver for PythonResolver {
56    fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
57        let raw = raw.trim_matches(|c| c == '"' || c == '\'');
58        if raw.is_empty() {
59            return None;
60        }
61
62        let check_exists = |path: &Path| -> bool {
63            let cache_val = self
64                .exists_cache
65                .read()
66                .ok()
67                .and_then(|cache| cache.get(path).copied());
68            if let Some(res) = cache_val {
69                return res;
70            }
71            let res = path.exists();
72            if let Ok(mut cache) = self.exists_cache.write() {
73                cache.insert(path.to_path_buf(), res);
74            }
75            res
76        };
77
78        if raw.starts_with('.') {
79            let relative = raw.trim_start_matches('.');
80            if relative.is_empty() {
81                return Some(source_dir.join("__init__.py"));
82            }
83            let path = source_dir.join(relative.replace('.', std::path::MAIN_SEPARATOR_STR));
84            let init_path = path.join("__init__.py");
85            if check_exists(&init_path) {
86                return Some(init_path);
87            }
88        } else {
89            let path = project_root.join(raw.replace('.', std::path::MAIN_SEPARATOR_STR));
90            let init_path = path.join("__init__.py");
91            if check_exists(&init_path) {
92                return Some(init_path);
93            }
94        }
95
96        (self.f)(raw, source_dir, project_root)
97    }
98}
99
100/// Stateful resolver for Go module import paths.
101pub struct GoModResolver {
102    f: fn(&str, &Path, &Path) -> Option<PathBuf>,
103    cached_module: OnceLock<Option<(PathBuf, String)>>,
104}
105
106impl GoModResolver {
107    pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
108        Self {
109            f,
110            cached_module: OnceLock::new(),
111        }
112    }
113}
114
115impl ImportResolver for GoModResolver {
116    fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
117        let raw = raw.trim_matches(|c| c == '"' || c == '\'');
118        if raw.is_empty() {
119            return None;
120        }
121
122        if let Some(relative) = raw.strip_prefix('.') {
123            let path = source_dir.join(relative);
124            return Some(path.with_extension("go"));
125        }
126
127        let module_info = self.cached_module.get_or_init(|| {
128            let mut current = Some(project_root);
129            while let Some(dir) = current {
130                let go_mod = dir.join("go.mod");
131                if go_mod.is_file() {
132                    if let Ok(content) = std::fs::read_to_string(&go_mod) {
133                        for line in content.lines() {
134                            let line = line.trim();
135                            if let Some(module) = line.strip_prefix("module ") {
136                                return Some((dir.to_path_buf(), module.trim().to_string()));
137                            }
138                        }
139                    }
140                    break;
141                }
142                current = dir.parent();
143            }
144            None
145        });
146
147        let matched_module = module_info.as_ref().and_then(|(dir, name)| {
148            if raw.starts_with(name) {
149                Some((dir, name))
150            } else {
151                None
152            }
153        });
154        if let Some((dir, module_name)) = matched_module {
155            let relative = raw[module_name.len()..].trim_start_matches('/');
156            return Some(dir.join(relative).with_extension("go"));
157        }
158
159        (self.f)(raw, source_dir, project_root)
160    }
161}
162
163/// Stateful resolver for JavaScript import paths.
164pub struct JsResolver {
165    f: fn(&str, &Path, &Path) -> Option<PathBuf>,
166    is_file_cache: RwLock<HashMap<PathBuf, bool>>,
167}
168
169impl JsResolver {
170    pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
171        Self {
172            f,
173            is_file_cache: RwLock::new(HashMap::new()),
174        }
175    }
176}
177
178impl ImportResolver for JsResolver {
179    fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
180        let raw = raw.trim_matches(|c| c == '"' || c == '\'');
181        if raw.is_empty() {
182            return None;
183        }
184
185        let check_is_file = |path: &Path| -> bool {
186            let cache_val = self
187                .is_file_cache
188                .read()
189                .ok()
190                .and_then(|cache| cache.get(path).copied());
191            if let Some(res) = cache_val {
192                return res;
193            }
194            let res = path.is_file();
195            if let Ok(mut cache) = self.is_file_cache.write() {
196                cache.insert(path.to_path_buf(), res);
197            }
198            res
199        };
200
201        if !raw.starts_with('.') && !raw.starts_with('/') {
202            return (self.f)(raw, source_dir, project_root);
203        }
204
205        let base = if raw.starts_with('/') {
206            PathBuf::from("/")
207        } else {
208            source_dir.to_path_buf()
209        };
210
211        let path = base.join(raw);
212
213        let extensions = ["", ".js", ".json", ".node", ".mjs", ".cjs"];
214        for ext in &extensions {
215            let candidate = if ext.is_empty() {
216                path.clone()
217            } else {
218                path.with_extension(ext.trim_start_matches('.'))
219            };
220            if check_is_file(&candidate) {
221                return Some(candidate);
222            }
223        }
224
225        (self.f)(raw, source_dir, project_root)
226    }
227}
228
229/// Stateful resolver for TypeScript import paths using `tsconfig.json`.
230pub struct TsConfigResolver {
231    f: fn(&str, &Path, &Path) -> Option<PathBuf>,
232    is_file_cache: RwLock<HashMap<PathBuf, bool>>,
233}
234
235impl TsConfigResolver {
236    pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
237        Self {
238            f,
239            is_file_cache: RwLock::new(HashMap::new()),
240        }
241    }
242}
243
244impl ImportResolver for TsConfigResolver {
245    fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
246        let raw = raw.trim_matches(|c| c == '"' || c == '\'');
247        if raw.is_empty() {
248            return None;
249        }
250
251        let check_is_file = |path: &Path| -> bool {
252            let cache_val = self
253                .is_file_cache
254                .read()
255                .ok()
256                .and_then(|cache| cache.get(path).copied());
257            if let Some(res) = cache_val {
258                return res;
259            }
260            let res = path.is_file();
261            if let Ok(mut cache) = self.is_file_cache.write() {
262                cache.insert(path.to_path_buf(), res);
263            }
264            res
265        };
266
267        if !raw.starts_with('.') && !raw.starts_with('/') {
268            return (self.f)(raw, source_dir, project_root);
269        }
270
271        let base = if raw.starts_with('/') {
272            PathBuf::from("/")
273        } else {
274            source_dir.to_path_buf()
275        };
276
277        let path = base.join(raw);
278
279        let extensions = ["", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"];
280        for ext in &extensions {
281            let candidate = if ext.is_empty() {
282                path.clone()
283            } else {
284                path.with_extension(ext.trim_start_matches('.'))
285            };
286            if check_is_file(&candidate) {
287                return Some(candidate);
288            }
289        }
290
291        (self.f)(raw, source_dir, project_root)
292    }
293}
294
295/// Construct a boxed `ImportResolver` for the given language.
296///
297/// Wraps the existing stateless fn pointer from `LanguageSpec` into
298/// a language-specific resolver (PythonResolver, TsConfigResolver, etc.)
299/// allowing gradual, modular migration to stateful resolution.
300pub fn make_resolver(lang: crate::language::LangId) -> Box<dyn ImportResolver> {
301    let f = lang.spec().import_path_resolver;
302    match lang {
303        crate::language::LangId::Python => Box::new(PythonResolver::new(f)),
304        crate::language::LangId::Go => Box::new(GoModResolver::new(f)),
305        crate::language::LangId::JavaScript => Box::new(JsResolver::new(f)),
306        crate::language::LangId::TypeScript | crate::language::LangId::Tsx => {
307            Box::new(TsConfigResolver::new(f))
308        }
309        _ => Box::new(StatelessResolver::new(f)),
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use std::path::{Path, PathBuf};
317
318    #[test]
319    fn stateless_resolver_delegates_to_fn() {
320        // A fn pointer that resolves "foo" to /proj/foo.py
321        fn my_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
322            Some(root.join(format!("{raw}.py")))
323        }
324        let resolver = StatelessResolver::new(my_resolver);
325        let result = resolver.resolve("foo", Path::new("/src"), Path::new("/proj"));
326        assert_eq!(result, Some(PathBuf::from("/proj/foo.py")));
327    }
328
329    #[test]
330    fn stateless_resolver_returns_none_for_unresolvable() {
331        fn null_resolver(_raw: &str, _src: &Path, _root: &Path) -> Option<PathBuf> {
332            None
333        }
334        let resolver = StatelessResolver::new(null_resolver);
335        let result = resolver.resolve("anything", Path::new("/src"), Path::new("/proj"));
336        assert!(result.is_none());
337    }
338
339    #[test]
340    fn make_resolver_returns_working_resolver_for_python() {
341        use crate::language::LangId;
342        let resolver = make_resolver(LangId::Python);
343        // Python should resolve "b" from /proj/a/ to /proj/a/b.py
344        let result = resolver.resolve("b", Path::new("/proj/a"), Path::new("/proj"));
345        // We just verify it doesn't panic and returns an Option
346        let _ = result; // may be None if /proj/a/b.py doesn't exist on disk - that's fine
347    }
348
349    #[test]
350    fn import_resolver_trait_is_object_safe() {
351        // This test verifies the trait can be used as a trait object
352        fn accepts_boxed(_resolver: &dyn ImportResolver) {}
353
354        fn null_resolver(_raw: &str, _src: &Path, _root: &Path) -> Option<PathBuf> {
355            None
356        }
357        let resolver = StatelessResolver::new(null_resolver);
358        accepts_boxed(&resolver);
359    }
360
361    #[test]
362    fn python_resolver_resolves_import() {
363        fn dummy_python_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
364            Some(root.join(format!("{raw}.py")))
365        }
366        let resolver = PythonResolver::new(dummy_python_resolver);
367        let result = resolver.resolve("test", Path::new("/src"), Path::new("/proj"));
368        assert_eq!(result, Some(PathBuf::from("/proj/test.py")));
369    }
370
371    #[test]
372    fn tsconfig_resolver_resolves_import() {
373        fn dummy_ts_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
374            Some(root.join(format!("{raw}.ts")))
375        }
376        let resolver = TsConfigResolver::new(dummy_ts_resolver);
377        let result = resolver.resolve("test", Path::new("/src"), Path::new("/proj"));
378        assert_eq!(result, Some(PathBuf::from("/proj/test.ts")));
379    }
380
381    #[test]
382    fn go_mod_resolver_resolves_import() {
383        fn dummy_go_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
384            Some(root.join(format!("{raw}.go")))
385        }
386        let resolver = GoModResolver::new(dummy_go_resolver);
387        let result = resolver.resolve("test", Path::new("/src"), Path::new("/proj"));
388        assert_eq!(result, Some(PathBuf::from("/proj/test.go")));
389    }
390
391    #[test]
392    fn go_mod_resolver_memoizes_go_mod_file() {
393        let temp_dir = std::env::temp_dir().join("go_mod_resolver_memoizes_go_mod_file");
394        if temp_dir.exists() {
395            let _ = std::fs::remove_dir_all(&temp_dir);
396        }
397        std::fs::create_dir_all(&temp_dir).unwrap();
398        let go_mod_path = temp_dir.join("go.mod");
399        std::fs::write(&go_mod_path, "module myproject\n").unwrap();
400
401        let resolver = make_resolver(crate::language::LangId::Go);
402
403        // First resolve: should succeed and read from disk
404        let res1 = resolver.resolve("myproject/sub", &temp_dir, &temp_dir);
405        assert_eq!(res1, Some(temp_dir.join("sub.go")));
406
407        // Delete go.mod from disk!
408        std::fs::remove_file(&go_mod_path).unwrap();
409
410        // Second resolve: should STILL succeed because the resolver memoized the module name!
411        let res2 = resolver.resolve("myproject/other", &temp_dir, &temp_dir);
412        assert_eq!(res2, Some(temp_dir.join("other.go")));
413
414        let _ = std::fs::remove_dir_all(&temp_dir);
415    }
416
417    #[test]
418    fn python_resolver_memoizes_exists_checks() {
419        let temp_dir = std::env::temp_dir().join("python_resolver_memoizes_exists_checks");
420        if temp_dir.exists() {
421            let _ = std::fs::remove_dir_all(&temp_dir);
422        }
423        std::fs::create_dir_all(&temp_dir).unwrap();
424        let pkg_dir = temp_dir.join("my_package");
425        std::fs::create_dir_all(&pkg_dir).unwrap();
426        let init_py = pkg_dir.join("__init__.py");
427        std::fs::write(&init_py, "").unwrap();
428
429        let resolver = make_resolver(crate::language::LangId::Python);
430
431        // First resolve: resolves to my_package/__init__.py
432        let res1 = resolver.resolve("my_package", &temp_dir, &temp_dir);
433        assert_eq!(res1, Some(init_py.clone()));
434
435        // Delete __init__.py from disk
436        std::fs::remove_file(&init_py).unwrap();
437
438        // Second resolve: should STILL return my_package/__init__.py because the resolver memoized the exists() result!
439        let res2 = resolver.resolve("my_package", &temp_dir, &temp_dir);
440        assert_eq!(res2, Some(init_py));
441
442        let _ = std::fs::remove_dir_all(&temp_dir);
443    }
444
445    #[test]
446    fn tsconfig_resolver_memoizes_is_file_checks() {
447        let temp_dir = std::env::temp_dir().join("tsconfig_resolver_memoizes_is_file_checks");
448        if temp_dir.exists() {
449            let _ = std::fs::remove_dir_all(&temp_dir);
450        }
451        std::fs::create_dir_all(&temp_dir).unwrap();
452        let ts_file = temp_dir.join("my_file.ts");
453        std::fs::write(&ts_file, "").unwrap();
454
455        let resolver = make_resolver(crate::language::LangId::TypeScript);
456
457        // First resolve: resolves to my_file.ts
458        let res1 = resolver.resolve("./my_file", &temp_dir, &temp_dir);
459        assert_eq!(res1, Some(ts_file.clone()));
460
461        // Delete my_file.ts
462        std::fs::remove_file(&ts_file).unwrap();
463
464        // Second resolve: should STILL return my_file.ts because it memoized the is_file() result!
465        let res2 = resolver.resolve("./my_file", &temp_dir, &temp_dir);
466        assert_eq!(res2, Some(ts_file));
467
468        let _ = std::fs::remove_dir_all(&temp_dir);
469    }
470}