1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::{OnceLock, RwLock};
10
11pub trait ImportResolver: Send + Sync {
17 fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf>;
18}
19
20pub 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
40pub 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
100pub 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
163pub 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
229pub 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
295pub 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 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 let result = resolver.resolve("b", Path::new("/proj/a"), Path::new("/proj"));
345 let _ = result; }
348
349 #[test]
350 fn import_resolver_trait_is_object_safe() {
351 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 let res1 = resolver.resolve("myproject/sub", &temp_dir, &temp_dir);
405 assert_eq!(res1, Some(temp_dir.join("sub.go")));
406
407 std::fs::remove_file(&go_mod_path).unwrap();
409
410 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 let res1 = resolver.resolve("my_package", &temp_dir, &temp_dir);
433 assert_eq!(res1, Some(init_py.clone()));
434
435 std::fs::remove_file(&init_py).unwrap();
437
438 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 let res1 = resolver.resolve("./my_file", &temp_dir, &temp_dir);
459 assert_eq!(res1, Some(ts_file.clone()));
460
461 std::fs::remove_file(&ts_file).unwrap();
463
464 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}