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::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    /// Invalidate any memoized filesystem state or configuration caches.
20    fn clear_cache(&self) {}
21}
22
23/// Strip quote chars that wrap an import specifier.
24pub(crate) fn strip_import_quotes(raw: &str) -> &str {
25    raw.trim_matches(|c| c == '"' || c == '\'')
26}
27
28/// Strip quote chars plus C/C++ bracket delimiters.
29pub(crate) fn strip_c_family_quotes(raw: &str) -> &str {
30    raw.trim_matches(|c| c == '<' || c == '>' || c == '"' || c == '\'')
31}
32
33/// Probe `base.join(raw)` with each extension in order.
34pub(crate) fn probe_relative(
35    raw: &str,
36    source_dir: &Path,
37    extensions: &[&str],
38    is_file: &dyn Fn(&Path) -> bool,
39    fallback: bool,
40) -> Option<PathBuf> {
41    let base = if raw.starts_with('/') {
42        PathBuf::from("/")
43    } else {
44        source_dir.to_path_buf()
45    };
46    let path = base.join(raw);
47    for ext in extensions {
48        let candidate = if ext.is_empty() {
49            path.clone()
50        } else {
51            path.with_extension(ext.trim_start_matches('.'))
52        };
53        if is_file(&candidate) {
54            return Some(candidate);
55        }
56    }
57    fallback.then(|| path.clone())
58}
59
60/// Shared stateless core for JS-family imports.
61///
62/// Bare specifiers return as external paths. Relative paths probe
63/// `base.join(raw)` with each extension in order.
64pub(crate) fn resolve_js_family_import(
65    raw: &str,
66    source_dir: &Path,
67    extensions: &[&str],
68    is_file: &dyn Fn(&Path) -> bool,
69) -> Option<PathBuf> {
70    let raw = strip_import_quotes(raw);
71    if raw.is_empty() {
72        return None;
73    }
74    if !raw.starts_with('.') && !raw.starts_with('/') {
75        return Some(PathBuf::from(raw));
76    }
77    probe_relative(raw, source_dir, extensions, is_file, true)
78}
79
80/// Resolve a C or C++ include specifier to a project file.
81///
82/// A system include (`<...>`) never resolves. There is no include path
83/// model, so a quoted include resolves only when the file sits next to the
84/// source file. Everything else stays external (ADR 0003).
85pub(crate) fn resolve_c_family_import(raw: &str, source_dir: &Path) -> Option<PathBuf> {
86    let trimmed = raw.trim();
87    if trimmed.is_empty() || trimmed.starts_with('<') {
88        return None;
89    }
90    let relative = strip_c_family_quotes(trimmed);
91    if relative.is_empty() {
92        return None;
93    }
94    let candidate = source_dir.join(relative);
95    let candidate = if candidate.extension().is_none() {
96        candidate.with_extension("h")
97    } else {
98        candidate
99    };
100    candidate.is_file().then_some(candidate)
101}
102
103/// Candidate `__init__.py` and module file for a Python import.
104///
105/// One leading dot is the current package, two dots the parent package, and
106/// so on (PEP 328).
107pub(crate) fn python_candidate_paths(
108    raw: &str,
109    source_dir: &Path,
110    project_root: &Path,
111) -> Option<(PathBuf, PathBuf)> {
112    let raw = strip_import_quotes(raw);
113    if raw.is_empty() {
114        return None;
115    }
116    if !raw.starts_with('.') {
117        let path = project_root.join(raw.replace('.', std::path::MAIN_SEPARATOR_STR));
118        let init = path.join("__init__.py");
119        let module = path.with_extension("py");
120        return Some((init, module));
121    }
122
123    let dots = raw.chars().take_while(|c| *c == '.').count();
124    let base = source_dir.ancestors().nth(dots - 1)?;
125    let rest = &raw[dots..];
126    if rest.is_empty() {
127        let init = base.join("__init__.py");
128        return Some((init.clone(), init));
129    }
130    let path = base.join(rest.replace('.', std::path::MAIN_SEPARATOR_STR));
131    let init = path.join("__init__.py");
132    let module = path.with_extension("py");
133    Some((init, module))
134}
135
136/// Walk `project_root` parents for `go.mod` and parse the module name.
137pub(crate) fn find_go_module(project_root: &Path) -> Option<(PathBuf, String)> {
138    let mut current = Some(project_root);
139    while let Some(dir) = current {
140        let go_mod = dir.join("go.mod");
141        if go_mod.is_file() {
142            if let Ok(content) = std::fs::read_to_string(&go_mod) {
143                for line in content.lines() {
144                    let line = line.trim();
145                    if let Some(module) = line.strip_prefix("module ") {
146                        return Some((dir.to_path_buf(), module.trim().to_string()));
147                    }
148                }
149            }
150            return None;
151        }
152        current = dir.parent();
153    }
154    None
155}
156
157/// Zero-cost adapter wrapping a stateless function pointer.
158///
159/// Bridges the existing `LanguageSpec.import_path_resolver` fn pointers
160/// to the `ImportResolver` trait without changing the `LanguageSpec` struct.
161pub struct StatelessResolver {
162    f: fn(&str, &Path, &Path) -> Option<PathBuf>,
163}
164
165impl StatelessResolver {
166    pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
167        Self { f }
168    }
169}
170
171impl ImportResolver for StatelessResolver {
172    fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
173        (self.f)(raw, source_dir, project_root)
174    }
175}
176
177/// Stateful resolver for Python import paths.
178///
179/// Only positive answers are memoized, so a module created after a miss is
180/// found on the next resolve instead of being answered from a stale entry.
181pub struct PythonResolver {
182    f: fn(&str, &Path, &Path) -> Option<PathBuf>,
183    exists_cache: RwLock<HashMap<PathBuf, bool>>,
184}
185
186impl PythonResolver {
187    pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
188        Self {
189            f,
190            exists_cache: RwLock::new(HashMap::new()),
191        }
192    }
193
194    pub fn clear_cache(&self) {
195        let mut cache = self
196            .exists_cache
197            .write()
198            .unwrap_or_else(|poison| poison.into_inner());
199        cache.clear();
200    }
201}
202
203impl ImportResolver for PythonResolver {
204    fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
205        let check_exists = |path: &Path| -> bool {
206            let cache_val = self
207                .exists_cache
208                .read()
209                .ok()
210                .and_then(|cache| cache.get(path).copied());
211            if let Some(res) = cache_val {
212                return res;
213            }
214            let res = path.exists();
215            if res && let Ok(mut cache) = self.exists_cache.write() {
216                cache.insert(path.to_path_buf(), res);
217            }
218            res
219        };
220
221        if let Some((init_path, _)) = python_candidate_paths(raw, source_dir, project_root) {
222            let stripped = strip_import_quotes(raw);
223            if stripped.trim_start_matches('.').is_empty() && stripped.starts_with('.') {
224                return Some(init_path);
225            }
226            if check_exists(&init_path) {
227                return Some(init_path);
228            }
229        } else {
230            return None;
231        }
232
233        (self.f)(raw, source_dir, project_root)
234    }
235
236    fn clear_cache(&self) {
237        PythonResolver::clear_cache(self);
238    }
239}
240
241/// Stateful resolver for Go module import paths.
242pub struct GoModResolver {
243    f: fn(&str, &Path, &Path) -> Option<PathBuf>,
244    cached_module: RwLock<Option<Option<(PathBuf, String)>>>,
245}
246
247impl GoModResolver {
248    pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
249        Self {
250            f,
251            cached_module: RwLock::new(None),
252        }
253    }
254
255    pub fn clear_cache(&self) {
256        let mut guard = self
257            .cached_module
258            .write()
259            .unwrap_or_else(|poison| poison.into_inner());
260        *guard = None;
261    }
262}
263
264impl ImportResolver for GoModResolver {
265    fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
266        let raw = strip_import_quotes(raw);
267        if raw.is_empty() || raw.starts_with('.') {
268            // Go modules reject relative imports, so there is no path to build.
269            return None;
270        }
271
272        let cached = self
273            .cached_module
274            .read()
275            .ok()
276            .and_then(|guard| guard.clone());
277        let module_info = match cached {
278            Some(info) => info,
279            None => {
280                // A missing go.mod is re-checked on the next resolve, so a
281                // module file written after the first miss is not cached away.
282                let computed = find_go_module(project_root);
283                if let Some(found) = computed.as_ref()
284                    && let Ok(mut guard) = self.cached_module.write()
285                {
286                    *guard = Some(Some(found.clone()));
287                }
288                computed
289            }
290        };
291
292        let matched_module = module_info.as_ref().and_then(|(dir, name)| {
293            raw.strip_prefix(name.as_str())
294                .filter(|rest| rest.is_empty() || rest.starts_with('/'))
295                .map(|_| (dir, name))
296        });
297        if let Some((dir, module_name)) = matched_module {
298            let relative = raw[module_name.len()..].trim_start_matches('/');
299            if relative.is_empty() {
300                // A package is a directory. One file cannot represent it.
301                return None;
302            }
303            return Some(dir.join(relative).with_extension("go"));
304        }
305
306        (self.f)(raw, source_dir, project_root)
307    }
308
309    fn clear_cache(&self) {
310        GoModResolver::clear_cache(self);
311    }
312}
313
314/// Stateful resolver for JavaScript-family import paths.
315///
316/// Parameterized by extension list so JS and TS/TSX share one core.
317/// JS probes Node-style extensions, TS probes TS-family extensions.
318pub struct NodeResolver {
319    f: fn(&str, &Path, &Path) -> Option<PathBuf>,
320    extensions: &'static [&'static str],
321    is_file_cache: RwLock<HashMap<PathBuf, bool>>,
322}
323
324/// Node-style extensions probed by the JS resolver.
325pub(crate) const JS_EXTS: &[&str] = &["", ".js", ".json", ".node", ".mjs", ".cjs"];
326/// TS-family extensions probed by the TS/TSX resolver.
327pub(crate) const TS_EXTS: &[&str] = &["", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"];
328
329impl NodeResolver {
330    pub fn new(
331        f: fn(&str, &Path, &Path) -> Option<PathBuf>,
332        extensions: &'static [&'static str],
333    ) -> Self {
334        Self {
335            f,
336            extensions,
337            is_file_cache: RwLock::new(HashMap::new()),
338        }
339    }
340
341    pub fn clear_cache(&self) {
342        let mut cache = self
343            .is_file_cache
344            .write()
345            .unwrap_or_else(|poison| poison.into_inner());
346        cache.clear();
347    }
348}
349
350impl ImportResolver for NodeResolver {
351    fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
352        let raw = strip_import_quotes(raw);
353        if raw.is_empty() {
354            return None;
355        }
356
357        let check_is_file = |path: &Path| -> bool {
358            let cache_val = self
359                .is_file_cache
360                .read()
361                .ok()
362                .and_then(|cache| cache.get(path).copied());
363            if let Some(res) = cache_val {
364                return res;
365            }
366            let res = path.is_file();
367            if res && let Ok(mut cache) = self.is_file_cache.write() {
368                cache.insert(path.to_path_buf(), res);
369            }
370            res
371        };
372
373        if !raw.starts_with('.') && !raw.starts_with('/') {
374            return (self.f)(raw, source_dir, project_root);
375        }
376
377        let path = if raw.starts_with('/') {
378            PathBuf::from("/").join(raw)
379        } else {
380            source_dir.join(raw)
381        };
382
383        for ext in self.extensions {
384            let candidate = if ext.is_empty() {
385                path.clone()
386            } else {
387                path.with_extension(ext.trim_start_matches('.'))
388            };
389            if check_is_file(&candidate) {
390                return Some(candidate);
391            }
392        }
393
394        (self.f)(raw, source_dir, project_root)
395    }
396
397    fn clear_cache(&self) {
398        NodeResolver::clear_cache(self);
399    }
400}
401
402/// Construct a boxed `ImportResolver` for the given language.
403///
404/// Wraps the existing stateless fn pointer from `LanguageSpec` into
405/// a language-specific resolver (PythonResolver, TsConfigResolver, etc.)
406/// allowing gradual, modular migration to stateful resolution.
407pub fn make_resolver(lang: crate::language::LangId) -> Box<dyn ImportResolver> {
408    let f = lang.spec().import_path_resolver;
409    match lang {
410        crate::language::LangId::Python => Box::new(PythonResolver::new(f)),
411        crate::language::LangId::Go => Box::new(GoModResolver::new(f)),
412        crate::language::LangId::JavaScript => Box::new(NodeResolver::new(f, JS_EXTS)),
413        crate::language::LangId::TypeScript | crate::language::LangId::Tsx => {
414            Box::new(NodeResolver::new(f, TS_EXTS))
415        }
416        _ => Box::new(StatelessResolver::new(f)),
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use std::path::{Path, PathBuf};
424
425    #[test]
426    fn stateless_resolver_delegates_to_fn() {
427        // A fn pointer that resolves "foo" to /proj/foo.py
428        fn my_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
429            Some(root.join(format!("{raw}.py")))
430        }
431        let resolver = StatelessResolver::new(my_resolver);
432        let result = resolver.resolve("foo", Path::new("/src"), Path::new("/proj"));
433        assert_eq!(result, Some(PathBuf::from("/proj/foo.py")));
434    }
435
436    #[test]
437    fn stateless_resolver_returns_none_for_unresolvable() {
438        fn null_resolver(_raw: &str, _src: &Path, _root: &Path) -> Option<PathBuf> {
439            None
440        }
441        let resolver = StatelessResolver::new(null_resolver);
442        let result = resolver.resolve("anything", Path::new("/src"), Path::new("/proj"));
443        assert!(result.is_none());
444    }
445
446    #[test]
447    fn make_resolver_returns_working_resolver_for_python() {
448        use crate::language::LangId;
449        let resolver = make_resolver(LangId::Python);
450        // Python should resolve "b" from /proj/a/ to /proj/a/b.py
451        let result = resolver.resolve("b", Path::new("/proj/a"), Path::new("/proj"));
452        // We just verify it doesn't panic and returns an Option
453        let _ = result; // may be None if /proj/a/b.py doesn't exist on disk - that's fine
454    }
455
456    #[test]
457    fn import_resolver_trait_is_object_safe() {
458        // This test verifies the trait can be used as a trait object
459        fn accepts_boxed(_resolver: &dyn ImportResolver) {}
460
461        fn null_resolver(_raw: &str, _src: &Path, _root: &Path) -> Option<PathBuf> {
462            None
463        }
464        let resolver = StatelessResolver::new(null_resolver);
465        accepts_boxed(&resolver);
466    }
467
468    #[test]
469    fn python_resolver_resolves_import() {
470        fn dummy_python_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
471            Some(root.join(format!("{raw}.py")))
472        }
473        let resolver = PythonResolver::new(dummy_python_resolver);
474        let result = resolver.resolve("test", Path::new("/src"), Path::new("/proj"));
475        assert_eq!(result, Some(PathBuf::from("/proj/test.py")));
476    }
477
478    #[test]
479    fn tsconfig_resolver_resolves_import() {
480        fn dummy_ts_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
481            Some(root.join(format!("{raw}.ts")))
482        }
483        let resolver = NodeResolver::new(dummy_ts_resolver, TS_EXTS);
484        let result = resolver.resolve("test", Path::new("/src"), Path::new("/proj"));
485        assert_eq!(result, Some(PathBuf::from("/proj/test.ts")));
486    }
487
488    #[test]
489    fn node_resolver_uses_configured_extensions() {
490        fn fallback(raw: &str, _source: &Path, _root: &Path) -> Option<PathBuf> {
491            Some(PathBuf::from(raw))
492        }
493        let temp = std::env::temp_dir().join("node_resolver_uses_configured_extensions");
494        if temp.exists() {
495            let _ = std::fs::remove_dir_all(&temp);
496        }
497        std::fs::create_dir_all(&temp).unwrap();
498        let target = temp.join("mod.json");
499        std::fs::write(&target, "{}").unwrap();
500
501        let js = NodeResolver::new(fallback, JS_EXTS);
502        assert_eq!(js.resolve("./mod", &temp, &temp), Some(target.clone()));
503
504        let ts = NodeResolver::new(fallback, TS_EXTS);
505        assert_eq!(
506            ts.resolve("./mod", &temp, &temp),
507            Some(PathBuf::from("./mod"))
508        );
509
510        let _ = std::fs::remove_dir_all(&temp);
511    }
512
513    #[test]
514    fn shared_quote_strip_helpers() {
515        assert_eq!(strip_import_quotes("\"react\""), "react");
516        assert_eq!(strip_c_family_quotes("<stdio.h>"), "stdio.h");
517    }
518
519    fn scratch(name: &str) -> PathBuf {
520        let dir = std::env::temp_dir().join(format!("meta_ast_resolver_{name}"));
521        let _ = std::fs::remove_dir_all(&dir);
522        std::fs::create_dir_all(&dir).unwrap();
523        dir
524    }
525
526    fn touch(path: &Path) {
527        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
528        std::fs::write(path, "").unwrap();
529    }
530
531    fn resolved(
532        lang: crate::language::LangId,
533        raw: &str,
534        source_dir: &Path,
535        root: &Path,
536    ) -> Option<PathBuf> {
537        make_resolver(lang).resolve(raw, source_dir, root)
538    }
539
540    #[test]
541    fn python_level_two_relative_import_uses_the_parent_package() {
542        let root = scratch("py_level_two");
543        let pkg = root.join("pkg");
544        touch(&root.join("util.py"));
545        touch(&pkg.join("util.py"));
546
547        let out = resolved(crate::language::LangId::Python, "..util", &pkg, &root);
548        assert_eq!(out, Some(root.join("util.py")));
549    }
550
551    #[test]
552    fn python_third_party_import_is_not_a_project_path() {
553        let root = scratch("py_third_party");
554        let pkg = root.join("pkg");
555        touch(&pkg.join("__init__.py"));
556
557        let out = resolved(crate::language::LangId::Python, "requests", &pkg, &root);
558        assert_eq!(out, None);
559    }
560
561    #[test]
562    fn python_first_party_relative_import_still_resolves() {
563        let root = scratch("py_first_party");
564        let pkg = root.join("pkg");
565        touch(&pkg.join("util.py"));
566
567        let out = resolved(crate::language::LangId::Python, ".util", &pkg, &root);
568        assert_eq!(out, Some(pkg.join("util.py")));
569    }
570
571    #[test]
572    fn python_dotted_import_resolves_when_the_file_exists() {
573        let root = scratch("py_dotted");
574        touch(&root.join("svc/api.py"));
575
576        let out = resolved(crate::language::LangId::Python, "svc.api", &root, &root);
577        assert_eq!(out, Some(root.join("svc/api.py")));
578    }
579
580    #[test]
581    fn go_relative_import_is_rejected() {
582        let root = scratch("go_relative");
583        let out = resolved(crate::language::LangId::Go, "\"./util\"", &root, &root);
584        assert_eq!(out, None);
585    }
586
587    #[test]
588    fn go_module_prefix_requires_a_boundary() {
589        let root = scratch("go_prefix");
590        std::fs::write(root.join("go.mod"), "module myproject\n").unwrap();
591
592        let out = resolved(
593            crate::language::LangId::Go,
594            "\"myproject2/pkg\"",
595            &root,
596            &root,
597        );
598        assert_eq!(out, None);
599    }
600
601    #[test]
602    fn go_module_import_of_a_subpackage_resolves() {
603        let root = scratch("go_subpackage");
604        std::fs::write(root.join("go.mod"), "module myproject\n").unwrap();
605
606        let out = resolved(
607            crate::language::LangId::Go,
608            "\"myproject/internal/util\"",
609            &root,
610            &root,
611        );
612        assert_eq!(out, Some(root.join("internal/util.go")));
613    }
614
615    #[test]
616    fn c_system_include_is_not_a_project_path() {
617        let root = scratch("c_system_include");
618        let src = root.join("src");
619        std::fs::create_dir_all(&src).unwrap();
620
621        let out = resolved(crate::language::LangId::C, "<stdio.h>", &src, &root);
622        assert_eq!(out, None);
623    }
624
625    #[test]
626    fn c_missing_quoted_include_is_not_a_project_path() {
627        let root = scratch("c_missing_include");
628        let src = root.join("src");
629        std::fs::create_dir_all(&src).unwrap();
630
631        let out = resolved(crate::language::LangId::C, "\"missing.h\"", &src, &root);
632        assert_eq!(out, None);
633    }
634
635    #[test]
636    fn c_existing_quoted_include_resolves() {
637        let root = scratch("c_existing_include");
638        let src = root.join("src");
639        touch(&src.join("local.h"));
640
641        let out = resolved(crate::language::LangId::C, "\"local.h\"", &src, &root);
642        assert_eq!(out, Some(src.join("local.h")));
643    }
644
645    #[test]
646    fn rust_crate_path_walks_all_segments() {
647        let root = scratch("rust_crate_path");
648        let src = root.join("src");
649        touch(&src.join("a/b.rs"));
650
651        let out = resolved(
652            crate::language::LangId::Rust,
653            "\"crate::a::b\"",
654            &src,
655            &root,
656        );
657        assert_eq!(out, Some(src.join("a/b.rs")));
658    }
659
660    #[test]
661    fn rust_self_path_resolves_within_the_module() {
662        let root = scratch("rust_self_path");
663        let src = root.join("src");
664        touch(&src.join("here.rs"));
665
666        let out = resolved(crate::language::LangId::Rust, "\"self::here\"", &src, &root);
667        assert_eq!(out, Some(src.join("here.rs")));
668    }
669
670    #[test]
671    fn go_mod_resolver_resolves_import() {
672        fn dummy_go_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
673            Some(root.join(format!("{raw}.go")))
674        }
675        let resolver = GoModResolver::new(dummy_go_resolver);
676        let result = resolver.resolve("test", Path::new("/src"), Path::new("/proj"));
677        assert_eq!(result, Some(PathBuf::from("/proj/test.go")));
678    }
679
680    #[test]
681    fn go_mod_resolver_memoizes_go_mod_file() {
682        let temp_dir = std::env::temp_dir().join("go_mod_resolver_memoizes_go_mod_file");
683        if temp_dir.exists() {
684            let _ = std::fs::remove_dir_all(&temp_dir);
685        }
686        std::fs::create_dir_all(&temp_dir).unwrap();
687        let go_mod_path = temp_dir.join("go.mod");
688        std::fs::write(&go_mod_path, "module myproject\n").unwrap();
689
690        let resolver = make_resolver(crate::language::LangId::Go);
691
692        // First resolve: should succeed and read from disk
693        let res1 = resolver.resolve("myproject/sub", &temp_dir, &temp_dir);
694        assert_eq!(res1, Some(temp_dir.join("sub.go")));
695
696        // Delete go.mod from disk!
697        std::fs::remove_file(&go_mod_path).unwrap();
698
699        // Second resolve: should STILL succeed because the resolver memoized the module name!
700        let res2 = resolver.resolve("myproject/other", &temp_dir, &temp_dir);
701        assert_eq!(res2, Some(temp_dir.join("other.go")));
702
703        let _ = std::fs::remove_dir_all(&temp_dir);
704    }
705
706    #[test]
707    fn python_resolver_memoizes_exists_checks() {
708        let temp_dir = std::env::temp_dir().join("python_resolver_memoizes_exists_checks");
709        if temp_dir.exists() {
710            let _ = std::fs::remove_dir_all(&temp_dir);
711        }
712        std::fs::create_dir_all(&temp_dir).unwrap();
713        let pkg_dir = temp_dir.join("my_package");
714        std::fs::create_dir_all(&pkg_dir).unwrap();
715        let init_py = pkg_dir.join("__init__.py");
716        std::fs::write(&init_py, "").unwrap();
717
718        let resolver = make_resolver(crate::language::LangId::Python);
719
720        // First resolve: resolves to my_package/__init__.py
721        let res1 = resolver.resolve("my_package", &temp_dir, &temp_dir);
722        assert_eq!(res1, Some(init_py.clone()));
723
724        // Delete __init__.py from disk
725        std::fs::remove_file(&init_py).unwrap();
726
727        // Second resolve: should STILL return my_package/__init__.py because the resolver memoized the exists() result!
728        let res2 = resolver.resolve("my_package", &temp_dir, &temp_dir);
729        assert_eq!(res2, Some(init_py));
730
731        let _ = std::fs::remove_dir_all(&temp_dir);
732    }
733
734    #[test]
735    fn tsconfig_resolver_memoizes_is_file_checks() {
736        let temp_dir = std::env::temp_dir().join("tsconfig_resolver_memoizes_is_file_checks");
737        if temp_dir.exists() {
738            let _ = std::fs::remove_dir_all(&temp_dir);
739        }
740        std::fs::create_dir_all(&temp_dir).unwrap();
741        let ts_file = temp_dir.join("my_file.ts");
742        std::fs::write(&ts_file, "").unwrap();
743
744        let resolver = make_resolver(crate::language::LangId::TypeScript);
745
746        // First resolve: resolves to my_file.ts
747        let res1 = resolver.resolve("./my_file", &temp_dir, &temp_dir);
748        assert_eq!(res1, Some(ts_file.clone()));
749
750        // Delete my_file.ts
751        std::fs::remove_file(&ts_file).unwrap();
752
753        // Second resolve: should STILL return my_file.ts because it memoized the is_file() result!
754        let res2 = resolver.resolve("./my_file", &temp_dir, &temp_dir);
755        assert_eq!(res2, Some(ts_file));
756
757        let _ = std::fs::remove_dir_all(&temp_dir);
758    }
759
760    #[test]
761    fn resolver_clear_cache_invalidates_memoized_state() {
762        let temp_dir = std::env::temp_dir().join("resolver_clear_cache_invalidates_memoized_state");
763        if temp_dir.exists() {
764            let _ = std::fs::remove_dir_all(&temp_dir);
765        }
766        std::fs::create_dir_all(&temp_dir).unwrap();
767
768        // 1. Python resolver cache invalidation
769        let pkg_dir = temp_dir.join("py_pkg");
770        std::fs::create_dir_all(&pkg_dir).unwrap();
771        let init_py = pkg_dir.join("__init__.py");
772        std::fs::write(&init_py, "").unwrap();
773        let py_resolver = make_resolver(crate::language::LangId::Python);
774        assert_eq!(
775            py_resolver.resolve("py_pkg", &temp_dir, &temp_dir),
776            Some(init_py.clone())
777        );
778        std::fs::remove_file(&init_py).unwrap();
779        // Still cached:
780        assert_eq!(
781            py_resolver.resolve("py_pkg", &temp_dir, &temp_dir),
782            Some(init_py.clone())
783        );
784        // Invalidate:
785        py_resolver.clear_cache();
786        assert_eq!(py_resolver.resolve("py_pkg", &temp_dir, &temp_dir), None);
787
788        // 2. Go resolver cache invalidation
789        let go_mod_path = temp_dir.join("go.mod");
790        std::fs::write(&go_mod_path, "module oldmod\n").unwrap();
791        let go_resolver = make_resolver(crate::language::LangId::Go);
792        assert_eq!(
793            go_resolver.resolve("oldmod/sub", &temp_dir, &temp_dir),
794            Some(temp_dir.join("sub.go"))
795        );
796        std::fs::write(&go_mod_path, "module newmod\n").unwrap();
797        // Still cached to oldmod:
798        assert_eq!(
799            go_resolver.resolve("oldmod/sub", &temp_dir, &temp_dir),
800            Some(temp_dir.join("sub.go"))
801        );
802        // Invalidate:
803        go_resolver.clear_cache();
804        assert_eq!(
805            go_resolver.resolve("oldmod/sub", &temp_dir, &temp_dir),
806            None
807        );
808        assert_eq!(
809            go_resolver.resolve("newmod/sub", &temp_dir, &temp_dir),
810            Some(temp_dir.join("sub.go"))
811        );
812
813        // 3. TypeScript resolver cache invalidation
814        let ts_file = temp_dir.join("ts_file.ts");
815        std::fs::write(&ts_file, "").unwrap();
816        let ts_resolver = make_resolver(crate::language::LangId::TypeScript);
817        assert_eq!(
818            ts_resolver.resolve("./ts_file", &temp_dir, &temp_dir),
819            Some(ts_file.clone())
820        );
821        std::fs::remove_file(&ts_file).unwrap();
822        // Still cached:
823        assert_eq!(
824            ts_resolver.resolve("./ts_file", &temp_dir, &temp_dir),
825            Some(ts_file)
826        );
827        // Invalidate:
828        ts_resolver.clear_cache();
829        assert_eq!(
830            ts_resolver.resolve("./ts_file", &temp_dir, &temp_dir),
831            Some(temp_dir.join("./ts_file"))
832        );
833
834        let _ = std::fs::remove_dir_all(&temp_dir);
835    }
836    #[test]
837    fn python_resolver_finds_a_module_created_after_a_miss() {
838        fn no_fallback(_raw: &str, _source: &Path, _root: &Path) -> Option<PathBuf> {
839            None
840        }
841
842        let root = std::env::temp_dir().join("resolver_python_created_after_miss");
843        let _ = std::fs::remove_dir_all(&root);
844        std::fs::create_dir_all(&root).unwrap();
845
846        let resolver = PythonResolver::new(no_fallback);
847        assert!(
848            resolver.resolve("pkg", &root, &root).is_none(),
849            "the module does not exist yet"
850        );
851
852        let (init, _module) = python_candidate_paths("pkg", &root, &root).unwrap();
853        std::fs::create_dir_all(init.parent().unwrap()).unwrap();
854        std::fs::write(&init, "").unwrap();
855
856        assert_eq!(
857            resolver.resolve("pkg", &root, &root),
858            Some(init),
859            "a module created after the first miss must resolve"
860        );
861
862        let _ = std::fs::remove_dir_all(&root);
863    }
864
865    #[test]
866    fn go_resolver_sees_a_module_file_created_after_a_miss() {
867        fn no_fallback(_raw: &str, _source: &Path, _root: &Path) -> Option<PathBuf> {
868            None
869        }
870
871        let root = std::env::temp_dir().join("resolver_go_created_after_miss");
872        let _ = std::fs::remove_dir_all(&root);
873        std::fs::create_dir_all(&root).unwrap();
874
875        let resolver = GoModResolver::new(no_fallback);
876        assert!(
877            resolver
878                .resolve("example.com/mod/util", &root, &root)
879                .is_none(),
880            "there is no go.mod yet"
881        );
882
883        std::fs::write(root.join("go.mod"), "module example.com/mod\n\ngo 1.22\n").unwrap();
884
885        assert_eq!(
886            resolver.resolve("example.com/mod/util", &root, &root),
887            Some(root.join("util").with_extension("go")),
888            "a go.mod written after the first miss must change the answer"
889        );
890
891        let _ = std::fs::remove_dir_all(&root);
892    }
893
894    #[test]
895    fn node_resolver_finds_a_file_created_after_a_miss() {
896        fn no_fallback(_raw: &str, _source: &Path, _root: &Path) -> Option<PathBuf> {
897            None
898        }
899
900        let root = std::env::temp_dir().join("resolver_node_created_after_miss");
901        let _ = std::fs::remove_dir_all(&root);
902        std::fs::create_dir_all(&root).unwrap();
903
904        let resolver = NodeResolver::new(no_fallback, JS_EXTS);
905        assert!(
906            resolver.resolve("./util", &root, &root).is_none(),
907            "the file does not exist yet"
908        );
909
910        let created = root.join("util.js");
911        std::fs::write(&created, "module.exports = {};\n").unwrap();
912
913        assert_eq!(
914            resolver.resolve("./util", &root, &root),
915            Some(created),
916            "a file created after the first miss must resolve"
917        );
918
919        let _ = std::fs::remove_dir_all(&root);
920    }
921}