1use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20use super::deep_queries::ImportInfo;
21
22#[derive(Debug, Clone)]
23pub struct ResolvedImport {
24 pub source: String,
25 pub resolved_path: Option<String>,
26 pub is_external: bool,
27 pub line: usize,
28}
29
30#[derive(Debug)]
31pub struct ResolverContext {
32 pub project_root: PathBuf,
33 pub file_paths: Vec<String>,
34 pub tsconfig_paths: HashMap<String, String>,
35 pub go_module: Option<String>,
36 pub dart_package: Option<String>,
37 file_set: std::collections::HashSet<String>,
38}
39
40impl ResolverContext {
41 pub fn new(project_root: &Path, file_paths: Vec<String>) -> Self {
42 let file_set: std::collections::HashSet<String> = file_paths.iter().cloned().collect();
43
44 let tsconfig_paths = load_tsconfig_paths(project_root);
45 let go_module = load_go_module(project_root);
46 let dart_package = load_dart_package(project_root);
47
48 Self {
49 project_root: project_root.to_path_buf(),
50 file_paths,
51 tsconfig_paths,
52 go_module,
53 dart_package,
54 file_set,
55 }
56 }
57
58 fn file_exists(&self, rel_path: &str) -> bool {
59 self.file_set.contains(rel_path)
60 }
61}
62
63pub fn resolve_imports(
64 imports: &[ImportInfo],
65 file_path: &str,
66 ext: &str,
67 ctx: &ResolverContext,
68) -> Vec<ResolvedImport> {
69 imports
70 .iter()
71 .map(|imp| {
72 let (resolved, is_external) = resolve_one(imp, file_path, ext, ctx);
73 ResolvedImport {
74 source: imp.source.clone(),
75 resolved_path: resolved,
76 is_external,
77 line: imp.line,
78 }
79 })
80 .collect()
81}
82
83fn resolve_one(
84 imp: &ImportInfo,
85 file_path: &str,
86 ext: &str,
87 ctx: &ResolverContext,
88) -> (Option<String>, bool) {
89 match ext {
90 "ts" | "tsx" | "js" | "jsx" => resolve_ts(imp, file_path, ctx),
91 "rs" => resolve_rust(imp, file_path, ctx),
92 "py" => resolve_python(imp, file_path, ctx),
93 "go" => resolve_go(imp, ctx),
94 "java" => resolve_java(imp, ctx),
95 "c" | "h" | "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => {
96 resolve_c_like(imp, file_path, ctx)
97 }
98 "rb" => resolve_ruby(imp, file_path, ctx),
99 "php" => resolve_php(imp, file_path, ctx),
100 "sh" | "bash" => resolve_bash(imp, file_path, ctx),
101 "dart" => resolve_dart(imp, file_path, ctx),
102 "zig" => resolve_zig(imp, file_path, ctx),
103 "kt" | "kts" => resolve_kotlin(imp, ctx),
104 "cs" => resolve_csharp(imp, ctx),
105 "swift" => resolve_swift(imp, file_path, ctx),
106 "scala" | "sc" => resolve_scala(imp, ctx),
107 "ex" | "exs" => resolve_elixir(imp, file_path, ctx),
108 _ => (None, true),
109 }
110}
111
112fn resolve_ts(imp: &ImportInfo, file_path: &str, ctx: &ResolverContext) -> (Option<String>, bool) {
117 let source = &imp.source;
118
119 if source.starts_with('.') {
120 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
121 let resolved = dir.join(source);
122 let normalized = normalize_path(&resolved);
123
124 if let Some(found) = try_ts_with_js_remap(&normalized, ctx) {
125 return (Some(found), false);
126 }
127 return (None, false);
128 }
129
130 if let Some(mapped) = resolve_tsconfig_path(source, ctx) {
131 return (Some(mapped), false);
132 }
133
134 (None, true)
135}
136
137fn try_ts_with_js_remap(base: &str, ctx: &ResolverContext) -> Option<String> {
141 const JS_TO_TS: &[(&str, &[&str])] = &[
142 (".js", &[".ts", ".tsx"]),
143 (".jsx", &[".tsx", ".ts"]),
144 (".mjs", &[".mts"]),
145 (".cjs", &[".cts"]),
146 ];
147
148 for &(js_ext, ts_exts) in JS_TO_TS {
149 if let Some(stem) = base.strip_suffix(js_ext) {
150 for ts_ext in ts_exts {
151 let candidate = format!("{stem}{ts_ext}");
152 if ctx.file_exists(&candidate) {
153 return Some(candidate);
154 }
155 }
156 }
157 }
158
159 try_ts_extensions(base, ctx)
160}
161
162fn try_ts_extensions(base: &str, ctx: &ResolverContext) -> Option<String> {
163 let extensions = [".ts", ".tsx", ".js", ".jsx", ".d.ts"];
164
165 if ctx.file_exists(base) {
166 return Some(base.to_string());
167 }
168
169 for ext in &extensions {
170 let with_ext = format!("{base}{ext}");
171 if ctx.file_exists(&with_ext) {
172 return Some(with_ext);
173 }
174 }
175
176 let index_extensions = ["index.ts", "index.tsx", "index.js", "index.jsx"];
177 for idx in &index_extensions {
178 let index_path = format!("{base}/{idx}");
179 if ctx.file_exists(&index_path) {
180 return Some(index_path);
181 }
182 }
183
184 None
185}
186
187fn resolve_tsconfig_path(source: &str, ctx: &ResolverContext) -> Option<String> {
188 for (pattern, target) in &ctx.tsconfig_paths {
189 let prefix = pattern.trim_end_matches('*');
190 if let Some(remainder) = source.strip_prefix(prefix) {
191 let target_base = target.trim_end_matches('*');
192 let candidate = format!("{target_base}{remainder}");
193 if let Some(found) = try_ts_with_js_remap(&candidate, ctx) {
194 return Some(found);
195 }
196 }
197 }
198 None
199}
200
201fn resolve_rust(
206 imp: &ImportInfo,
207 file_path: &str,
208 ctx: &ResolverContext,
209) -> (Option<String>, bool) {
210 let source = &imp.source;
211
212 if source.starts_with("crate::")
213 || source.starts_with("super::")
214 || source.starts_with("self::")
215 {
216 let cleaned = source.replace("crate::", "").replace("self::", "");
217
218 let resolved = if source.starts_with("super::") {
219 let dir = Path::new(file_path).parent().and_then(|p| p.parent());
220 match dir {
221 Some(d) => {
222 let rest = source.trim_start_matches("super::");
223 d.join(rest.replace("::", "/"))
224 .to_string_lossy()
225 .to_string()
226 }
227 None => cleaned.replace("::", "/"),
228 }
229 } else {
230 cleaned.replace("::", "/")
231 };
232
233 if let Some(found) = try_rust_paths(&resolved, ctx) {
234 return (Some(found), false);
235 }
236 return (None, false);
237 }
238
239 let parts: Vec<&str> = source.split("::").collect();
240 if parts.is_empty() {
241 return (None, true);
242 }
243
244 let is_external = !source.starts_with("crate")
245 && !source.starts_with("super")
246 && !source.starts_with("self")
247 && !ctx.file_paths.iter().any(|f| {
248 let stem = Path::new(f)
249 .file_stem()
250 .and_then(|s| s.to_str())
251 .unwrap_or("");
252 stem == parts[0] || f.contains(&format!("/{}/", parts[0]))
253 });
254
255 if is_external {
256 return (None, true);
257 }
258
259 let as_path = source.replace("::", "/");
260 if let Some(found) = try_rust_paths(&as_path, ctx) {
261 return (Some(found), false);
262 }
263
264 (None, is_external)
265}
266
267fn try_rust_paths(base: &str, ctx: &ResolverContext) -> Option<String> {
268 let prefixes = ["", "src/", "rust/src/"];
269 for prefix in &prefixes {
270 let candidate = format!("{prefix}{base}.rs");
271 if ctx.file_exists(&candidate) {
272 return Some(candidate);
273 }
274 let mod_candidate = format!("{prefix}{base}/mod.rs");
275 if ctx.file_exists(&mod_candidate) {
276 return Some(mod_candidate);
277 }
278 }
279
280 let parts: Vec<&str> = base.rsplitn(2, '/').collect();
281 if parts.len() == 2 {
282 let parent = parts[1];
283 for prefix in &prefixes {
284 let candidate = format!("{prefix}{parent}.rs");
285 if ctx.file_exists(&candidate) {
286 return Some(candidate);
287 }
288 }
289 }
290
291 None
292}
293
294fn resolve_python(
299 imp: &ImportInfo,
300 file_path: &str,
301 ctx: &ResolverContext,
302) -> (Option<String>, bool) {
303 let source = &imp.source;
304
305 if source.starts_with('.') {
306 let dot_count = source.chars().take_while(|c| *c == '.').count();
307 let module_part = &source[dot_count..];
308
309 let mut dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
310 for _ in 1..dot_count {
311 dir = dir.parent().unwrap_or(Path::new(""));
312 }
313
314 let as_path = module_part.replace('.', "/");
315 let base = if as_path.is_empty() {
316 dir.to_string_lossy().to_string()
317 } else {
318 format!("{}/{as_path}", dir.display())
319 };
320
321 if let Some(found) = try_python_paths(&base, ctx) {
322 return (Some(found), false);
323 }
324 return (None, false);
325 }
326
327 let as_path = source.replace('.', "/");
328
329 if let Some(found) = try_python_paths(&as_path, ctx) {
330 return (Some(found), false);
331 }
332
333 let is_stdlib = is_python_stdlib(source);
334 (
335 None,
336 is_stdlib || !ctx.file_paths.iter().any(|f| f.contains(&as_path)),
337 )
338}
339
340fn try_python_paths(base: &str, ctx: &ResolverContext) -> Option<String> {
341 let py_file = format!("{base}.py");
342 if ctx.file_exists(&py_file) {
343 return Some(py_file);
344 }
345
346 let init_file = format!("{base}/__init__.py");
347 if ctx.file_exists(&init_file) {
348 return Some(init_file);
349 }
350
351 let prefixes = ["src/", "lib/"];
352 for prefix in &prefixes {
353 let candidate = format!("{prefix}{base}.py");
354 if ctx.file_exists(&candidate) {
355 return Some(candidate);
356 }
357 let init = format!("{prefix}{base}/__init__.py");
358 if ctx.file_exists(&init) {
359 return Some(init);
360 }
361 }
362
363 None
364}
365
366fn is_python_stdlib(module: &str) -> bool {
367 let first = module.split('.').next().unwrap_or(module);
368 matches!(
369 first,
370 "os" | "sys"
371 | "json"
372 | "re"
373 | "math"
374 | "datetime"
375 | "typing"
376 | "collections"
377 | "itertools"
378 | "functools"
379 | "pathlib"
380 | "io"
381 | "abc"
382 | "enum"
383 | "dataclasses"
384 | "logging"
385 | "unittest"
386 | "argparse"
387 | "subprocess"
388 | "threading"
389 | "multiprocessing"
390 | "socket"
391 | "http"
392 | "urllib"
393 | "hashlib"
394 | "hmac"
395 | "secrets"
396 | "time"
397 | "copy"
398 | "pprint"
399 | "textwrap"
400 | "shutil"
401 | "tempfile"
402 | "glob"
403 | "fnmatch"
404 | "contextlib"
405 | "inspect"
406 | "importlib"
407 | "pickle"
408 | "shelve"
409 | "csv"
410 | "configparser"
411 | "struct"
412 | "codecs"
413 | "string"
414 | "difflib"
415 | "ast"
416 | "dis"
417 | "traceback"
418 | "warnings"
419 | "concurrent"
420 | "asyncio"
421 | "signal"
422 | "select"
423 )
424}
425
426fn resolve_go(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
431 let source = &imp.source;
432
433 if let Some(ref go_mod) = ctx.go_module {
434 if source.starts_with(go_mod.as_str()) {
435 let relative = source.strip_prefix(go_mod.as_str()).unwrap_or(source);
436 let relative = relative.trim_start_matches('/');
437
438 if let Some(found) = try_go_package(relative, ctx) {
439 return (Some(found), false);
440 }
441 return (None, false);
442 }
443 }
444
445 if let Some(found) = try_go_package(source, ctx) {
446 return (Some(found), false);
447 }
448
449 (None, true)
450}
451
452fn try_go_package(pkg_path: &str, ctx: &ResolverContext) -> Option<String> {
453 for file in &ctx.file_paths {
454 let p = Path::new(file.as_str());
455 if p.extension().and_then(|e| e.to_str()) != Some("go") {
456 continue;
457 }
458 if file.ends_with("_test.go") {
459 continue;
460 }
461 let dir = p.parent()?.to_string_lossy();
462 if dir == pkg_path || dir.ends_with(pkg_path) {
463 return Some(file.clone());
464 }
465 }
466 None
467}
468
469fn resolve_java(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
474 let source = &imp.source;
475
476 if source.starts_with("java.") || source.starts_with("javax.") || source.starts_with("sun.") {
477 return (None, true);
478 }
479
480 let parts: Vec<&str> = source.rsplitn(2, '.').collect();
481 if parts.len() < 2 {
482 return (None, true);
483 }
484
485 let class_name = parts[0];
486 let package_path = parts[1].replace('.', "/");
487 let file_path = format!("{package_path}/{class_name}.java");
488
489 let search_roots = ["", "src/main/java/", "src/", "app/src/main/java/"];
490 for root in &search_roots {
491 let candidate = format!("{root}{file_path}");
492 if ctx.file_exists(&candidate) {
493 return (Some(candidate), false);
494 }
495 }
496
497 (
498 None,
499 !ctx.file_paths.iter().any(|f| f.contains(&package_path)),
500 )
501}
502
503fn resolve_kotlin(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
508 let source = &imp.source;
509
510 if source.starts_with("java.")
511 || source.starts_with("javax.")
512 || source.starts_with("kotlin.")
513 || source.starts_with("kotlinx.")
514 || source.starts_with("android.")
515 || source.starts_with("androidx.")
516 || source.starts_with("org.junit.")
517 || source.starts_with("org.jetbrains.")
518 {
519 return (None, true);
520 }
521
522 let parts: Vec<&str> = source.rsplitn(2, '.').collect();
523 if parts.len() < 2 {
524 return (None, true);
525 }
526
527 let class_name = parts[0];
528 let package_path = parts[1].replace('.', "/");
529
530 let search_roots = [
531 "",
532 "src/main/kotlin/",
533 "src/main/java/",
534 "src/",
535 "app/src/main/kotlin/",
536 "app/src/main/java/",
537 "src/commonMain/kotlin/",
538 ];
539
540 for root in &search_roots {
541 for ext in &["kt", "kts", "java"] {
542 let candidate = format!("{root}{package_path}/{class_name}.{ext}");
543 if ctx.file_exists(&candidate) {
544 return (Some(candidate), false);
545 }
546 }
547 }
548
549 (
550 None,
551 !ctx.file_paths.iter().any(|f| f.contains(&package_path)),
552 )
553}
554
555fn resolve_c_like(
560 imp: &ImportInfo,
561 file_path: &str,
562 ctx: &ResolverContext,
563) -> (Option<String>, bool) {
564 let source = imp.source.trim();
565 if source.is_empty() {
566 return (None, true);
567 }
568
569 let try_prefixes = |prefixes: &[&str], rel: &str| -> Option<String> {
570 let rel = rel.trim_start_matches("./").trim_start_matches('/');
571 let mut candidates: Vec<String> = vec![rel.to_string()];
572 for ext in [".h", ".hpp", ".c", ".cpp"] {
573 if !rel.ends_with(ext) {
574 candidates.push(format!("{rel}{ext}"));
575 }
576 }
577 for prefix in prefixes {
578 for c in &candidates {
579 let p = format!("{prefix}{c}");
580 if ctx.file_exists(&p) {
581 return Some(p);
582 }
583 }
584 }
585 None
586 };
587
588 if source.starts_with('.') {
589 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
590 let dir_prefix = if dir.as_os_str().is_empty() {
591 String::new()
592 } else {
593 format!("{}/", dir.to_string_lossy())
594 };
595 if let Some(found) = try_prefixes(&[dir_prefix.as_str()], source) {
596 return (Some(found), false);
597 }
598 return (None, false);
599 }
600
601 if ctx.file_exists(source) {
602 return (Some(source.to_string()), false);
603 }
604
605 if let Some(found) = try_prefixes(&["", "include/", "src/"], source) {
606 return (Some(found), false);
607 }
608
609 (None, true)
610}
611
612fn resolve_ruby(
617 imp: &ImportInfo,
618 file_path: &str,
619 ctx: &ResolverContext,
620) -> (Option<String>, bool) {
621 let source = imp.source.trim();
622 if source.is_empty() {
623 return (None, true);
624 }
625 let source_rel = source.trim_start_matches("./").trim_start_matches('/');
626
627 let try_prefixes = |prefixes: &[&str]| -> Option<String> {
628 let mut candidates: Vec<String> = vec![source_rel.to_string()];
629 if !Path::new(source_rel)
630 .extension()
631 .is_some_and(|e| e.eq_ignore_ascii_case("rb"))
632 {
633 candidates.push(format!("{source_rel}.rb"));
634 }
635 for prefix in prefixes {
636 for c in &candidates {
637 let p = format!("{prefix}{c}");
638 if ctx.file_exists(&p) {
639 return Some(p);
640 }
641 }
642 }
643 None
644 };
645
646 if source.starts_with('.') || source_rel.contains('/') {
647 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
648 let dir_prefix = if dir.as_os_str().is_empty() {
649 String::new()
650 } else {
651 format!("{}/", dir.to_string_lossy())
652 };
653 if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
654 return (Some(found), false);
655 }
656 if let Some(found) = try_prefixes(&["", "lib/", "src/"]) {
657 return (Some(found), false);
658 }
659 return (None, false);
660 }
661
662 (None, true)
663}
664
665fn resolve_php(imp: &ImportInfo, file_path: &str, ctx: &ResolverContext) -> (Option<String>, bool) {
670 let source = imp.source.trim();
671 if source.is_empty() {
672 return (None, true);
673 }
674 if source.starts_with("http://") || source.starts_with("https://") {
675 return (None, true);
676 }
677 let source_rel = source.trim_start_matches("./").trim_start_matches('/');
678
679 let try_prefixes = |prefixes: &[&str]| -> Option<String> {
680 let mut candidates: Vec<String> = vec![source_rel.to_string()];
681 if !Path::new(source_rel)
682 .extension()
683 .is_some_and(|e| e.eq_ignore_ascii_case("php"))
684 {
685 candidates.push(format!("{source_rel}.php"));
686 }
687 for prefix in prefixes {
688 for c in &candidates {
689 let p = format!("{prefix}{c}");
690 if ctx.file_exists(&p) {
691 return Some(p);
692 }
693 }
694 }
695 None
696 };
697
698 if source.starts_with('.') || source.starts_with('/') || source_rel.contains('/') {
699 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
700 let dir_prefix = if dir.as_os_str().is_empty() {
701 String::new()
702 } else {
703 format!("{}/", dir.to_string_lossy())
704 };
705 if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
706 return (Some(found), false);
707 }
708 if let Some(found) = try_prefixes(&["", "src/", "lib/"]) {
709 return (Some(found), false);
710 }
711 return (None, false);
712 }
713
714 (None, true)
715}
716
717fn resolve_bash(
722 imp: &ImportInfo,
723 file_path: &str,
724 ctx: &ResolverContext,
725) -> (Option<String>, bool) {
726 let source = imp.source.trim();
727 if source.is_empty() {
728 return (None, true);
729 }
730 let source_rel = source.trim_start_matches("./").trim_start_matches('/');
731
732 let try_prefixes = |prefixes: &[&str]| -> Option<String> {
733 let mut candidates: Vec<String> = vec![source_rel.to_string()];
734 if !Path::new(source_rel)
735 .extension()
736 .is_some_and(|e| e.eq_ignore_ascii_case("sh"))
737 {
738 candidates.push(format!("{source_rel}.sh"));
739 }
740 for prefix in prefixes {
741 for c in &candidates {
742 let p = format!("{prefix}{c}");
743 if ctx.file_exists(&p) {
744 return Some(p);
745 }
746 }
747 }
748 None
749 };
750
751 if source.starts_with('.') || source.starts_with('/') || source_rel.contains('/') {
752 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
753 let dir_prefix = if dir.as_os_str().is_empty() {
754 String::new()
755 } else {
756 format!("{}/", dir.to_string_lossy())
757 };
758 if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
759 return (Some(found), false);
760 }
761 if let Some(found) = try_prefixes(&["", "scripts/", "bin/"]) {
762 return (Some(found), false);
763 }
764 return (None, false);
765 }
766
767 (None, true)
768}
769
770fn resolve_dart(
775 imp: &ImportInfo,
776 file_path: &str,
777 ctx: &ResolverContext,
778) -> (Option<String>, bool) {
779 let source = imp.source.trim();
780 if source.is_empty() {
781 return (None, true);
782 }
783 if source.starts_with("dart:") {
784 return (None, true);
785 }
786
787 let try_prefixes = |prefixes: &[&str], rel: &str| -> Option<String> {
788 let rel = rel.trim_start_matches("./").trim_start_matches('/').trim();
789 let mut candidates: Vec<String> = vec![rel.to_string()];
790 if !Path::new(rel)
791 .extension()
792 .is_some_and(|e| e.eq_ignore_ascii_case("dart"))
793 {
794 candidates.push(format!("{rel}.dart"));
795 }
796 for prefix in prefixes {
797 for c in &candidates {
798 let p = format!("{prefix}{c}");
799 if ctx.file_exists(&p) {
800 return Some(p);
801 }
802 }
803 }
804 None
805 };
806
807 if source.starts_with("package:") {
808 if let Some(pkg) = ctx.dart_package.as_deref() {
809 let prefix = format!("package:{pkg}/");
810 if let Some(rest) = source.strip_prefix(&prefix) {
811 if let Some(found) = try_prefixes(&["lib/", ""], rest) {
812 return (Some(found), false);
813 }
814 return (None, false);
815 }
816 }
817 return (None, true);
818 }
819
820 if source.starts_with('.') || source.starts_with('/') {
821 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
822 let dir_prefix = if dir.as_os_str().is_empty() {
823 String::new()
824 } else {
825 format!("{}/", dir.to_string_lossy())
826 };
827 if let Some(found) = try_prefixes(&[dir_prefix.as_str()], source) {
828 return (Some(found), false);
829 }
830 if let Some(found) = try_prefixes(&["", "lib/"], source) {
831 return (Some(found), false);
832 }
833 return (None, false);
834 }
835
836 (None, true)
837}
838
839fn resolve_zig(imp: &ImportInfo, file_path: &str, ctx: &ResolverContext) -> (Option<String>, bool) {
844 let source = imp.source.trim();
845 if source.is_empty() {
846 return (None, true);
847 }
848 let source_rel = source.trim_start_matches("./").trim_start_matches('/');
849 if source_rel == "std" {
850 return (None, true);
851 }
852
853 let try_prefixes = |prefixes: &[&str]| -> Option<String> {
854 let mut candidates: Vec<String> = vec![source_rel.to_string()];
855 if !Path::new(source_rel)
856 .extension()
857 .is_some_and(|e| e.eq_ignore_ascii_case("zig"))
858 {
859 candidates.push(format!("{source_rel}.zig"));
860 }
861 for prefix in prefixes {
862 for c in &candidates {
863 let p = format!("{prefix}{c}");
864 if ctx.file_exists(&p) {
865 return Some(p);
866 }
867 }
868 }
869 None
870 };
871
872 if source.starts_with('.')
873 || source_rel.contains('/')
874 || Path::new(source_rel)
875 .extension()
876 .is_some_and(|e| e.eq_ignore_ascii_case("zig"))
877 {
878 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
879 let dir_prefix = if dir.as_os_str().is_empty() {
880 String::new()
881 } else {
882 format!("{}/", dir.to_string_lossy())
883 };
884 if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
885 return (Some(found), false);
886 }
887 if let Some(found) = try_prefixes(&["", "src/"]) {
888 return (Some(found), false);
889 }
890 return (None, false);
891 }
892
893 (None, true)
894}
895
896fn load_tsconfig_paths(root: &Path) -> HashMap<String, String> {
901 let mut paths = HashMap::new();
902
903 let candidates = ["tsconfig.json", "tsconfig.base.json", "jsconfig.json"];
904 for name in &candidates {
905 let tsconfig_path = root.join(name);
906 if let Ok(content) = std::fs::read_to_string(&tsconfig_path) {
907 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
908 if let Some(compiler) = json.get("compilerOptions") {
909 let base_url = compiler
910 .get("baseUrl")
911 .and_then(|v| v.as_str())
912 .unwrap_or(".");
913
914 if let Some(path_map) = compiler.get("paths").and_then(|v| v.as_object()) {
915 for (pattern, targets) in path_map {
916 if let Some(first_target) = targets
917 .as_array()
918 .and_then(|a| a.first())
919 .and_then(|v| v.as_str())
920 {
921 let resolved = if base_url == "." {
922 first_target.to_string()
923 } else {
924 format!("{base_url}/{first_target}")
925 };
926 paths.insert(pattern.clone(), resolved);
927 }
928 }
929 }
930 }
931 }
932 break;
933 }
934 }
935
936 paths
937}
938
939fn load_go_module(root: &Path) -> Option<String> {
940 let go_mod = root.join("go.mod");
941 let content = std::fs::read_to_string(go_mod).ok()?;
942 for line in content.lines() {
943 let trimmed = line.trim();
944 if trimmed.starts_with("module ") {
945 return Some(trimmed.strip_prefix("module ")?.trim().to_string());
946 }
947 }
948 None
949}
950
951fn load_dart_package(root: &Path) -> Option<String> {
952 let pubspec = root.join("pubspec.yaml");
953 let content = std::fs::read_to_string(pubspec).ok()?;
954 for line in content.lines() {
955 let trimmed = line.trim();
956 if let Some(rest) = trimmed.strip_prefix("name:") {
957 let name = rest.trim();
958 if !name.is_empty() {
959 return Some(name.to_string());
960 }
961 }
962 }
963 None
964}
965
966fn normalize_path(path: &Path) -> String {
971 let mut parts: Vec<&str> = Vec::new();
972 for component in path.components() {
973 match component {
974 std::path::Component::ParentDir => {
975 parts.pop();
976 }
977 std::path::Component::Normal(s) => {
978 parts.push(s.to_str().unwrap_or(""));
979 }
980 _ => {}
981 }
982 }
983 parts.join("/")
984}
985
986fn resolve_csharp(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
991 let source = &imp.source;
992
993 if source.starts_with("System.") || source.starts_with("Microsoft.") {
994 return (None, true);
995 }
996
997 let parts: Vec<&str> = source.rsplitn(2, '.').collect();
998 if parts.len() < 2 {
999 return (None, true);
1000 }
1001
1002 let class_name = parts[0];
1003 let namespace_path = parts[1].replace('.', "/");
1004 let file_path = format!("{namespace_path}/{class_name}.cs");
1005
1006 if ctx.file_exists(&file_path) {
1007 return (Some(file_path), false);
1008 }
1009 let flat = format!("{class_name}.cs");
1010 if ctx.file_exists(&flat) {
1011 return (Some(flat), false);
1012 }
1013 (None, false)
1014}
1015
1016fn resolve_swift(
1021 imp: &ImportInfo,
1022 file_path: &str,
1023 ctx: &ResolverContext,
1024) -> (Option<String>, bool) {
1025 let source = &imp.source;
1026
1027 if matches!(
1028 source.as_str(),
1029 "Foundation" | "UIKit" | "SwiftUI" | "Combine" | "CoreData" | "Darwin"
1030 ) {
1031 return (None, true);
1032 }
1033
1034 let dir = Path::new(file_path)
1035 .parent()
1036 .map(|p| p.to_string_lossy().to_string())
1037 .unwrap_or_default();
1038
1039 let candidate = if dir.is_empty() {
1040 format!("{source}.swift")
1041 } else {
1042 format!("{dir}/{source}.swift")
1043 };
1044 if ctx.file_exists(&candidate) {
1045 return (Some(candidate), false);
1046 }
1047 (None, false)
1048}
1049
1050fn resolve_scala(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
1055 let source = &imp.source;
1056
1057 if source.starts_with("scala.") || source.starts_with("java.") {
1058 return (None, true);
1059 }
1060
1061 let parts: Vec<&str> = source.rsplitn(2, '.').collect();
1062 if parts.len() < 2 {
1063 return (None, true);
1064 }
1065
1066 let name = parts[0];
1067 let package_path = parts[1].replace('.', "/");
1068
1069 for ext in ["scala", "sc"] {
1070 let candidate = format!("{package_path}/{name}.{ext}");
1071 if ctx.file_exists(&candidate) {
1072 return (Some(candidate), false);
1073 }
1074 }
1075 (None, false)
1076}
1077
1078fn resolve_elixir(
1083 imp: &ImportInfo,
1084 file_path: &str,
1085 ctx: &ResolverContext,
1086) -> (Option<String>, bool) {
1087 let source = &imp.source;
1088
1089 if source.starts_with("Kernel") || source.starts_with("Enum") || source.starts_with("IO") {
1090 return (None, true);
1091 }
1092
1093 let snake =
1094 source
1095 .replace('.', "/")
1096 .chars()
1097 .enumerate()
1098 .fold(String::new(), |mut acc, (i, c)| {
1099 if c.is_uppercase() && i > 0 && !acc.ends_with('/') {
1100 acc.push('_');
1101 }
1102 acc.push(c.to_ascii_lowercase());
1103 acc
1104 });
1105
1106 let dir = Path::new(file_path)
1107 .parent()
1108 .map(|p| p.to_string_lossy().to_string())
1109 .unwrap_or_default();
1110
1111 for ext in ["ex", "exs"] {
1112 let candidate = format!("lib/{snake}.{ext}");
1113 if ctx.file_exists(&candidate) {
1114 return (Some(candidate), false);
1115 }
1116 if !dir.is_empty() {
1117 let local = format!("{dir}/{snake}.{ext}");
1118 if ctx.file_exists(&local) {
1119 return (Some(local), false);
1120 }
1121 }
1122 }
1123 (None, false)
1124}
1125
1126#[cfg(test)]
1131mod tests {
1132 use super::*;
1133 use crate::core::deep_queries::{ImportInfo, ImportKind};
1134
1135 fn make_ctx(files: &[&str]) -> ResolverContext {
1136 ResolverContext {
1137 project_root: PathBuf::from("/project"),
1138 file_paths: files.iter().map(std::string::ToString::to_string).collect(),
1139 tsconfig_paths: HashMap::new(),
1140 go_module: None,
1141 dart_package: None,
1142 file_set: files.iter().map(std::string::ToString::to_string).collect(),
1143 }
1144 }
1145
1146 fn make_import(source: &str) -> ImportInfo {
1147 ImportInfo {
1148 source: source.to_string(),
1149 names: Vec::new(),
1150 kind: ImportKind::Named,
1151 line: 1,
1152 is_type_only: false,
1153 }
1154 }
1155
1156 #[test]
1159 fn ts_relative_import() {
1160 let ctx = make_ctx(&["src/components/Button.tsx", "src/utils/helpers.ts"]);
1161 let imp = make_import("./helpers");
1162 let results = resolve_imports(&[imp], "src/utils/index.ts", "ts", &ctx);
1163 assert_eq!(
1164 results[0].resolved_path.as_deref(),
1165 Some("src/utils/helpers.ts")
1166 );
1167 assert!(!results[0].is_external);
1168 }
1169
1170 #[test]
1171 fn ts_relative_parent() {
1172 let ctx = make_ctx(&["src/utils.ts", "src/components/Button.tsx"]);
1173 let imp = make_import("../utils");
1174 let results = resolve_imports(&[imp], "src/components/Button.tsx", "ts", &ctx);
1175 assert_eq!(results[0].resolved_path.as_deref(), Some("src/utils.ts"));
1176 }
1177
1178 #[test]
1179 fn ts_index_file() {
1180 let ctx = make_ctx(&["src/components/index.ts", "src/app.ts"]);
1181 let imp = make_import("./components");
1182 let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
1183 assert_eq!(
1184 results[0].resolved_path.as_deref(),
1185 Some("src/components/index.ts")
1186 );
1187 }
1188
1189 #[test]
1190 fn ts_relative_js_specifier_resolves_to_ts_source() {
1191 let ctx = make_ctx(&["src/b.ts", "src/a.ts"]);
1192 let imp = make_import("./b.js");
1193 let results = resolve_imports(&[imp], "src/a.ts", "ts", &ctx);
1194 assert_eq!(results[0].resolved_path.as_deref(), Some("src/b.ts"));
1195 assert!(!results[0].is_external);
1196 }
1197
1198 #[test]
1199 fn ts_relative_jsx_specifier_resolves_to_tsx_source() {
1200 let ctx = make_ctx(&["src/Button.tsx", "src/App.tsx"]);
1201 let imp = make_import("./Button.jsx");
1202 let results = resolve_imports(&[imp], "src/App.tsx", "tsx", &ctx);
1203 assert_eq!(results[0].resolved_path.as_deref(), Some("src/Button.tsx"));
1204 }
1205
1206 #[test]
1207 fn ts_relative_mjs_specifier_resolves_to_mts_source() {
1208 let ctx = make_ctx(&["src/utils.mts", "src/main.mts"]);
1209 let imp = make_import("./utils.mjs");
1210 let results = resolve_imports(&[imp], "src/main.mts", "ts", &ctx);
1211 assert_eq!(results[0].resolved_path.as_deref(), Some("src/utils.mts"));
1212 }
1213
1214 #[test]
1215 fn ts_relative_js_specifier_falls_back_to_js_file() {
1216 let ctx = make_ctx(&["src/legacy.js", "src/app.ts"]);
1217 let imp = make_import("./legacy.js");
1218 let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
1219 assert_eq!(results[0].resolved_path.as_deref(), Some("src/legacy.js"));
1220 }
1221
1222 #[test]
1223 fn ts_external_package() {
1224 let ctx = make_ctx(&["src/app.ts"]);
1225 let imp = make_import("react");
1226 let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
1227 assert!(results[0].is_external);
1228 assert!(results[0].resolved_path.is_none());
1229 }
1230
1231 #[test]
1232 fn ts_tsconfig_paths() {
1233 let mut ctx = make_ctx(&["src/lib/utils/format.ts"]);
1234 ctx.tsconfig_paths
1235 .insert("@utils/*".to_string(), "src/lib/utils/*".to_string());
1236 let imp = make_import("@utils/format");
1237 let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
1238 assert_eq!(
1239 results[0].resolved_path.as_deref(),
1240 Some("src/lib/utils/format.ts")
1241 );
1242 assert!(!results[0].is_external);
1243 }
1244
1245 #[test]
1248 fn rust_crate_import() {
1249 let ctx = make_ctx(&["src/core/session.rs", "src/main.rs"]);
1250 let imp = make_import("crate::core::session");
1251 let results = resolve_imports(&[imp], "src/server.rs", "rs", &ctx);
1252 assert_eq!(
1253 results[0].resolved_path.as_deref(),
1254 Some("src/core/session.rs")
1255 );
1256 assert!(!results[0].is_external);
1257 }
1258
1259 #[test]
1260 fn rust_mod_rs() {
1261 let ctx = make_ctx(&["src/core/mod.rs", "src/main.rs"]);
1262 let imp = make_import("crate::core");
1263 let results = resolve_imports(&[imp], "src/main.rs", "rs", &ctx);
1264 assert_eq!(results[0].resolved_path.as_deref(), Some("src/core/mod.rs"));
1265 }
1266
1267 #[test]
1268 fn rust_external_crate() {
1269 let ctx = make_ctx(&["src/main.rs"]);
1270 let imp = make_import("anyhow::Result");
1271 let results = resolve_imports(&[imp], "src/main.rs", "rs", &ctx);
1272 assert!(results[0].is_external);
1273 }
1274
1275 #[test]
1276 fn rust_symbol_in_module() {
1277 let ctx = make_ctx(&["src/core/session.rs"]);
1278 let imp = make_import("crate::core::session::SessionState");
1279 let results = resolve_imports(&[imp], "src/server.rs", "rs", &ctx);
1280 assert_eq!(
1281 results[0].resolved_path.as_deref(),
1282 Some("src/core/session.rs")
1283 );
1284 }
1285
1286 #[test]
1289 fn python_absolute_import() {
1290 let ctx = make_ctx(&["models/user.py", "app.py"]);
1291 let imp = make_import("models.user");
1292 let results = resolve_imports(&[imp], "app.py", "py", &ctx);
1293 assert_eq!(results[0].resolved_path.as_deref(), Some("models/user.py"));
1294 }
1295
1296 #[test]
1297 fn python_package_init() {
1298 let ctx = make_ctx(&["utils/__init__.py", "app.py"]);
1299 let imp = make_import("utils");
1300 let results = resolve_imports(&[imp], "app.py", "py", &ctx);
1301 assert_eq!(
1302 results[0].resolved_path.as_deref(),
1303 Some("utils/__init__.py")
1304 );
1305 }
1306
1307 #[test]
1308 fn python_relative_import() {
1309 let ctx = make_ctx(&["pkg/utils.py", "pkg/main.py"]);
1310 let imp = make_import(".utils");
1311 let results = resolve_imports(&[imp], "pkg/main.py", "py", &ctx);
1312 assert_eq!(results[0].resolved_path.as_deref(), Some("pkg/utils.py"));
1313 }
1314
1315 #[test]
1316 fn python_stdlib() {
1317 let ctx = make_ctx(&["app.py"]);
1318 let imp = make_import("os");
1319 let results = resolve_imports(&[imp], "app.py", "py", &ctx);
1320 assert!(results[0].is_external);
1321 }
1322
1323 #[test]
1326 fn go_internal_package() {
1327 let mut ctx = make_ctx(&["cmd/server/main.go", "internal/auth/auth.go"]);
1328 ctx.go_module = Some("github.com/org/project".to_string());
1329 let imp = make_import("github.com/org/project/internal/auth");
1330 let results = resolve_imports(&[imp], "cmd/server/main.go", "go", &ctx);
1331 assert_eq!(
1332 results[0].resolved_path.as_deref(),
1333 Some("internal/auth/auth.go")
1334 );
1335 assert!(!results[0].is_external);
1336 }
1337
1338 #[test]
1339 fn go_external_package() {
1340 let ctx = make_ctx(&["main.go"]);
1341 let imp = make_import("fmt");
1342 let results = resolve_imports(&[imp], "main.go", "go", &ctx);
1343 assert!(results[0].is_external);
1344 }
1345
1346 #[test]
1349 fn java_internal_class() {
1350 let ctx = make_ctx(&[
1351 "src/main/java/com/example/service/UserService.java",
1352 "src/main/java/com/example/model/User.java",
1353 ]);
1354 let imp = make_import("com.example.model.User");
1355 let results = resolve_imports(
1356 &[imp],
1357 "src/main/java/com/example/service/UserService.java",
1358 "java",
1359 &ctx,
1360 );
1361 assert_eq!(
1362 results[0].resolved_path.as_deref(),
1363 Some("src/main/java/com/example/model/User.java")
1364 );
1365 assert!(!results[0].is_external);
1366 }
1367
1368 #[test]
1369 fn java_stdlib() {
1370 let ctx = make_ctx(&["Main.java"]);
1371 let imp = make_import("java.util.List");
1372 let results = resolve_imports(&[imp], "Main.java", "java", &ctx);
1373 assert!(results[0].is_external);
1374 }
1375
1376 #[test]
1379 fn empty_imports() {
1380 let ctx = make_ctx(&["src/main.rs"]);
1381 let results = resolve_imports(&[], "src/main.rs", "rs", &ctx);
1382 assert!(results.is_empty());
1383 }
1384
1385 #[test]
1386 fn unsupported_language() {
1387 let ctx = make_ctx(&["main.rb"]);
1388 let imp = make_import("some_module");
1389 let results = resolve_imports(&[imp], "main.rb", "rb", &ctx);
1390 assert!(results[0].is_external);
1391 }
1392
1393 #[test]
1394 fn c_include_resolves_from_include_dir() {
1395 let ctx = make_ctx(&["include/foo/bar.h", "src/main.c"]);
1396 let imp = make_import("foo/bar.h");
1397 let results = resolve_imports(&[imp], "src/main.c", "c", &ctx);
1398 assert_eq!(
1399 results[0].resolved_path.as_deref(),
1400 Some("include/foo/bar.h")
1401 );
1402 assert!(!results[0].is_external);
1403 }
1404
1405 #[test]
1406 fn ruby_require_relative_resolves() {
1407 let ctx = make_ctx(&["lib/utils.rb", "app.rb"]);
1408 let imp = make_import("./lib/utils");
1409 let results = resolve_imports(&[imp], "app.rb", "rb", &ctx);
1410 assert_eq!(results[0].resolved_path.as_deref(), Some("lib/utils.rb"));
1411 assert!(!results[0].is_external);
1412 }
1413
1414 #[test]
1415 fn php_require_resolves() {
1416 let ctx = make_ctx(&["vendor/autoload.php", "index.php"]);
1417 let imp = make_import("./vendor/autoload.php");
1418 let results = resolve_imports(&[imp], "index.php", "php", &ctx);
1419 assert_eq!(
1420 results[0].resolved_path.as_deref(),
1421 Some("vendor/autoload.php")
1422 );
1423 assert!(!results[0].is_external);
1424 }
1425
1426 #[test]
1427 fn bash_source_resolves() {
1428 let ctx = make_ctx(&["scripts/env.sh", "main.sh"]);
1429 let imp = make_import("./scripts/env.sh");
1430 let results = resolve_imports(&[imp], "main.sh", "sh", &ctx);
1431 assert_eq!(results[0].resolved_path.as_deref(), Some("scripts/env.sh"));
1432 assert!(!results[0].is_external);
1433 }
1434
1435 #[test]
1436 fn dart_package_import_resolves_to_lib() {
1437 let mut ctx = make_ctx(&["lib/src/util.dart", "lib/app.dart"]);
1438 ctx.dart_package = Some("myapp".to_string());
1439 let imp = make_import("package:myapp/src/util.dart");
1440 let results = resolve_imports(&[imp], "lib/app.dart", "dart", &ctx);
1441 assert_eq!(
1442 results[0].resolved_path.as_deref(),
1443 Some("lib/src/util.dart")
1444 );
1445 assert!(!results[0].is_external);
1446 }
1447
1448 #[test]
1449 fn kotlin_import_resolves_to_src_main_kotlin() {
1450 let ctx = make_ctx(&[
1451 "src/main/kotlin/com/example/service/UserService.kt",
1452 "src/main/kotlin/com/example/App.kt",
1453 ]);
1454 let imp = make_import("com.example.service.UserService");
1455 let results = resolve_imports(&[imp], "src/main/kotlin/com/example/App.kt", "kt", &ctx);
1456 assert_eq!(
1457 results[0].resolved_path.as_deref(),
1458 Some("src/main/kotlin/com/example/service/UserService.kt")
1459 );
1460 assert!(!results[0].is_external);
1461 }
1462
1463 #[test]
1464 fn kotlin_stdlib_import_is_external() {
1465 let ctx = make_ctx(&["src/main/kotlin/App.kt"]);
1466 let imp = make_import("kotlin.collections.List");
1467 let results = resolve_imports(&[imp], "src/main/kotlin/App.kt", "kt", &ctx);
1468 assert!(results[0].is_external);
1469 }
1470
1471 #[test]
1472 fn kotlin_import_resolves_java_file() {
1473 let ctx = make_ctx(&[
1474 "src/main/java/com/example/LegacyUtil.java",
1475 "src/main/kotlin/com/example/App.kt",
1476 ]);
1477 let imp = make_import("com.example.LegacyUtil");
1478 let results = resolve_imports(&[imp], "src/main/kotlin/com/example/App.kt", "kt", &ctx);
1479 assert_eq!(
1480 results[0].resolved_path.as_deref(),
1481 Some("src/main/java/com/example/LegacyUtil.java")
1482 );
1483 assert!(!results[0].is_external);
1484 }
1485}