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 _ => (None, true),
105 }
106}
107
108fn resolve_ts(imp: &ImportInfo, file_path: &str, ctx: &ResolverContext) -> (Option<String>, bool) {
113 let source = &imp.source;
114
115 if source.starts_with('.') {
116 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
117 let resolved = dir.join(source);
118 let normalized = normalize_path(&resolved);
119
120 if let Some(found) = try_ts_extensions(&normalized, ctx) {
121 return (Some(found), false);
122 }
123 return (None, false);
124 }
125
126 if let Some(mapped) = resolve_tsconfig_path(source, ctx) {
127 return (Some(mapped), false);
128 }
129
130 (None, true)
131}
132
133fn try_ts_extensions(base: &str, ctx: &ResolverContext) -> Option<String> {
134 let extensions = [".ts", ".tsx", ".js", ".jsx", ".d.ts"];
135
136 if ctx.file_exists(base) {
137 return Some(base.to_string());
138 }
139
140 for ext in &extensions {
141 let with_ext = format!("{base}{ext}");
142 if ctx.file_exists(&with_ext) {
143 return Some(with_ext);
144 }
145 }
146
147 let index_extensions = ["index.ts", "index.tsx", "index.js", "index.jsx"];
148 for idx in &index_extensions {
149 let index_path = format!("{base}/{idx}");
150 if ctx.file_exists(&index_path) {
151 return Some(index_path);
152 }
153 }
154
155 None
156}
157
158fn resolve_tsconfig_path(source: &str, ctx: &ResolverContext) -> Option<String> {
159 for (pattern, target) in &ctx.tsconfig_paths {
160 let prefix = pattern.trim_end_matches('*');
161 if let Some(remainder) = source.strip_prefix(prefix) {
162 let target_base = target.trim_end_matches('*');
163 let candidate = format!("{target_base}{remainder}");
164 if let Some(found) = try_ts_extensions(&candidate, ctx) {
165 return Some(found);
166 }
167 }
168 }
169 None
170}
171
172fn resolve_rust(
177 imp: &ImportInfo,
178 file_path: &str,
179 ctx: &ResolverContext,
180) -> (Option<String>, bool) {
181 let source = &imp.source;
182
183 if source.starts_with("crate::")
184 || source.starts_with("super::")
185 || source.starts_with("self::")
186 {
187 let cleaned = source.replace("crate::", "").replace("self::", "");
188
189 let resolved = if source.starts_with("super::") {
190 let dir = Path::new(file_path).parent().and_then(|p| p.parent());
191 match dir {
192 Some(d) => {
193 let rest = source.trim_start_matches("super::");
194 d.join(rest.replace("::", "/"))
195 .to_string_lossy()
196 .to_string()
197 }
198 None => cleaned.replace("::", "/"),
199 }
200 } else {
201 cleaned.replace("::", "/")
202 };
203
204 if let Some(found) = try_rust_paths(&resolved, ctx) {
205 return (Some(found), false);
206 }
207 return (None, false);
208 }
209
210 let parts: Vec<&str> = source.split("::").collect();
211 if parts.is_empty() {
212 return (None, true);
213 }
214
215 let is_external = !source.starts_with("crate")
216 && !ctx.file_paths.iter().any(|f| {
217 let stem = Path::new(f)
218 .file_stem()
219 .and_then(|s| s.to_str())
220 .unwrap_or("");
221 stem == parts[0]
222 });
223
224 if is_external {
225 return (None, true);
226 }
227
228 let as_path = source.replace("::", "/");
229 if let Some(found) = try_rust_paths(&as_path, ctx) {
230 return (Some(found), false);
231 }
232
233 (None, is_external)
234}
235
236fn try_rust_paths(base: &str, ctx: &ResolverContext) -> Option<String> {
237 let prefixes = ["", "src/", "rust/src/"];
238 for prefix in &prefixes {
239 let candidate = format!("{prefix}{base}.rs");
240 if ctx.file_exists(&candidate) {
241 return Some(candidate);
242 }
243 let mod_candidate = format!("{prefix}{base}/mod.rs");
244 if ctx.file_exists(&mod_candidate) {
245 return Some(mod_candidate);
246 }
247 }
248
249 let parts: Vec<&str> = base.rsplitn(2, '/').collect();
250 if parts.len() == 2 {
251 let parent = parts[1];
252 for prefix in &prefixes {
253 let candidate = format!("{prefix}{parent}.rs");
254 if ctx.file_exists(&candidate) {
255 return Some(candidate);
256 }
257 }
258 }
259
260 None
261}
262
263fn resolve_python(
268 imp: &ImportInfo,
269 file_path: &str,
270 ctx: &ResolverContext,
271) -> (Option<String>, bool) {
272 let source = &imp.source;
273
274 if source.starts_with('.') {
275 let dot_count = source.chars().take_while(|c| *c == '.').count();
276 let module_part = &source[dot_count..];
277
278 let mut dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
279 for _ in 1..dot_count {
280 dir = dir.parent().unwrap_or(Path::new(""));
281 }
282
283 let as_path = module_part.replace('.', "/");
284 let base = if as_path.is_empty() {
285 dir.to_string_lossy().to_string()
286 } else {
287 format!("{}/{as_path}", dir.display())
288 };
289
290 if let Some(found) = try_python_paths(&base, ctx) {
291 return (Some(found), false);
292 }
293 return (None, false);
294 }
295
296 let as_path = source.replace('.', "/");
297
298 if let Some(found) = try_python_paths(&as_path, ctx) {
299 return (Some(found), false);
300 }
301
302 let is_stdlib = is_python_stdlib(source);
303 (
304 None,
305 is_stdlib || !ctx.file_paths.iter().any(|f| f.contains(&as_path)),
306 )
307}
308
309fn try_python_paths(base: &str, ctx: &ResolverContext) -> Option<String> {
310 let py_file = format!("{base}.py");
311 if ctx.file_exists(&py_file) {
312 return Some(py_file);
313 }
314
315 let init_file = format!("{base}/__init__.py");
316 if ctx.file_exists(&init_file) {
317 return Some(init_file);
318 }
319
320 let prefixes = ["src/", "lib/"];
321 for prefix in &prefixes {
322 let candidate = format!("{prefix}{base}.py");
323 if ctx.file_exists(&candidate) {
324 return Some(candidate);
325 }
326 let init = format!("{prefix}{base}/__init__.py");
327 if ctx.file_exists(&init) {
328 return Some(init);
329 }
330 }
331
332 None
333}
334
335fn is_python_stdlib(module: &str) -> bool {
336 let first = module.split('.').next().unwrap_or(module);
337 matches!(
338 first,
339 "os" | "sys"
340 | "json"
341 | "re"
342 | "math"
343 | "datetime"
344 | "typing"
345 | "collections"
346 | "itertools"
347 | "functools"
348 | "pathlib"
349 | "io"
350 | "abc"
351 | "enum"
352 | "dataclasses"
353 | "logging"
354 | "unittest"
355 | "argparse"
356 | "subprocess"
357 | "threading"
358 | "multiprocessing"
359 | "socket"
360 | "http"
361 | "urllib"
362 | "hashlib"
363 | "hmac"
364 | "secrets"
365 | "time"
366 | "copy"
367 | "pprint"
368 | "textwrap"
369 | "shutil"
370 | "tempfile"
371 | "glob"
372 | "fnmatch"
373 | "contextlib"
374 | "inspect"
375 | "importlib"
376 | "pickle"
377 | "shelve"
378 | "csv"
379 | "configparser"
380 | "struct"
381 | "codecs"
382 | "string"
383 | "difflib"
384 | "ast"
385 | "dis"
386 | "traceback"
387 | "warnings"
388 | "concurrent"
389 | "asyncio"
390 | "signal"
391 | "select"
392 )
393}
394
395fn resolve_go(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
400 let source = &imp.source;
401
402 if let Some(ref go_mod) = ctx.go_module {
403 if source.starts_with(go_mod.as_str()) {
404 let relative = source.strip_prefix(go_mod.as_str()).unwrap_or(source);
405 let relative = relative.trim_start_matches('/');
406
407 if let Some(found) = try_go_package(relative, ctx) {
408 return (Some(found), false);
409 }
410 return (None, false);
411 }
412 }
413
414 if let Some(found) = try_go_package(source, ctx) {
415 return (Some(found), false);
416 }
417
418 (None, true)
419}
420
421fn try_go_package(pkg_path: &str, ctx: &ResolverContext) -> Option<String> {
422 for file in &ctx.file_paths {
423 if file.ends_with(".go") {
424 let dir = Path::new(file).parent()?.to_string_lossy();
425 if dir == pkg_path || dir.ends_with(pkg_path) {
426 return Some(dir.to_string());
427 }
428 }
429 }
430 None
431}
432
433fn resolve_java(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
438 let source = &imp.source;
439
440 if source.starts_with("java.") || source.starts_with("javax.") || source.starts_with("sun.") {
441 return (None, true);
442 }
443
444 let parts: Vec<&str> = source.rsplitn(2, '.').collect();
445 if parts.len() < 2 {
446 return (None, true);
447 }
448
449 let class_name = parts[0];
450 let package_path = parts[1].replace('.', "/");
451 let file_path = format!("{package_path}/{class_name}.java");
452
453 let search_roots = ["", "src/main/java/", "src/", "app/src/main/java/"];
454 for root in &search_roots {
455 let candidate = format!("{root}{file_path}");
456 if ctx.file_exists(&candidate) {
457 return (Some(candidate), false);
458 }
459 }
460
461 (
462 None,
463 !ctx.file_paths.iter().any(|f| f.contains(&package_path)),
464 )
465}
466
467fn resolve_kotlin(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
472 let source = &imp.source;
473
474 if source.starts_with("java.")
475 || source.starts_with("javax.")
476 || source.starts_with("kotlin.")
477 || source.starts_with("kotlinx.")
478 || source.starts_with("android.")
479 || source.starts_with("androidx.")
480 || source.starts_with("org.junit.")
481 || source.starts_with("org.jetbrains.")
482 {
483 return (None, true);
484 }
485
486 let parts: Vec<&str> = source.rsplitn(2, '.').collect();
487 if parts.len() < 2 {
488 return (None, true);
489 }
490
491 let class_name = parts[0];
492 let package_path = parts[1].replace('.', "/");
493
494 let search_roots = [
495 "",
496 "src/main/kotlin/",
497 "src/main/java/",
498 "src/",
499 "app/src/main/kotlin/",
500 "app/src/main/java/",
501 "src/commonMain/kotlin/",
502 ];
503
504 for root in &search_roots {
505 for ext in &["kt", "kts", "java"] {
506 let candidate = format!("{root}{package_path}/{class_name}.{ext}");
507 if ctx.file_exists(&candidate) {
508 return (Some(candidate), false);
509 }
510 }
511 }
512
513 (
514 None,
515 !ctx.file_paths.iter().any(|f| f.contains(&package_path)),
516 )
517}
518
519fn resolve_c_like(
524 imp: &ImportInfo,
525 file_path: &str,
526 ctx: &ResolverContext,
527) -> (Option<String>, bool) {
528 let source = imp.source.trim();
529 if source.is_empty() {
530 return (None, true);
531 }
532
533 let try_prefixes = |prefixes: &[&str], rel: &str| -> Option<String> {
534 let rel = rel.trim_start_matches("./").trim_start_matches('/');
535 let mut candidates: Vec<String> = vec![rel.to_string()];
536 for ext in [".h", ".hpp", ".c", ".cpp"] {
537 if !rel.ends_with(ext) {
538 candidates.push(format!("{rel}{ext}"));
539 }
540 }
541 for prefix in prefixes {
542 for c in candidates.iter() {
543 let p = format!("{prefix}{c}");
544 if ctx.file_exists(&p) {
545 return Some(p);
546 }
547 }
548 }
549 None
550 };
551
552 if source.starts_with('.') {
553 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
554 let dir_prefix = if dir.as_os_str().is_empty() {
555 "".to_string()
556 } else {
557 format!("{}/", dir.to_string_lossy())
558 };
559 if let Some(found) = try_prefixes(&[dir_prefix.as_str()], source) {
560 return (Some(found), false);
561 }
562 return (None, false);
563 }
564
565 if ctx.file_exists(source) {
566 return (Some(source.to_string()), false);
567 }
568
569 if let Some(found) = try_prefixes(&["", "include/", "src/"], source) {
570 return (Some(found), false);
571 }
572
573 (None, true)
574}
575
576fn resolve_ruby(
581 imp: &ImportInfo,
582 file_path: &str,
583 ctx: &ResolverContext,
584) -> (Option<String>, bool) {
585 let source = imp.source.trim();
586 if source.is_empty() {
587 return (None, true);
588 }
589 let source_rel = source.trim_start_matches("./").trim_start_matches('/');
590
591 let try_prefixes = |prefixes: &[&str]| -> Option<String> {
592 let mut candidates: Vec<String> = vec![source_rel.to_string()];
593 if !source_rel.ends_with(".rb") {
594 candidates.push(format!("{source_rel}.rb"));
595 }
596 for prefix in prefixes {
597 for c in candidates.iter() {
598 let p = format!("{prefix}{c}");
599 if ctx.file_exists(&p) {
600 return Some(p);
601 }
602 }
603 }
604 None
605 };
606
607 if source.starts_with('.') || source_rel.contains('/') {
608 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
609 let dir_prefix = if dir.as_os_str().is_empty() {
610 "".to_string()
611 } else {
612 format!("{}/", dir.to_string_lossy())
613 };
614 if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
615 return (Some(found), false);
616 }
617 if let Some(found) = try_prefixes(&["", "lib/", "src/"]) {
618 return (Some(found), false);
619 }
620 return (None, false);
621 }
622
623 (None, true)
624}
625
626fn resolve_php(imp: &ImportInfo, file_path: &str, ctx: &ResolverContext) -> (Option<String>, bool) {
631 let source = imp.source.trim();
632 if source.is_empty() {
633 return (None, true);
634 }
635 if source.starts_with("http://") || source.starts_with("https://") {
636 return (None, true);
637 }
638 let source_rel = source.trim_start_matches("./").trim_start_matches('/');
639
640 let try_prefixes = |prefixes: &[&str]| -> Option<String> {
641 let mut candidates: Vec<String> = vec![source_rel.to_string()];
642 if !source_rel.ends_with(".php") {
643 candidates.push(format!("{source_rel}.php"));
644 }
645 for prefix in prefixes {
646 for c in candidates.iter() {
647 let p = format!("{prefix}{c}");
648 if ctx.file_exists(&p) {
649 return Some(p);
650 }
651 }
652 }
653 None
654 };
655
656 if source.starts_with('.') || source.starts_with('/') || source_rel.contains('/') {
657 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
658 let dir_prefix = if dir.as_os_str().is_empty() {
659 "".to_string()
660 } else {
661 format!("{}/", dir.to_string_lossy())
662 };
663 if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
664 return (Some(found), false);
665 }
666 if let Some(found) = try_prefixes(&["", "src/", "lib/"]) {
667 return (Some(found), false);
668 }
669 return (None, false);
670 }
671
672 (None, true)
673}
674
675fn resolve_bash(
680 imp: &ImportInfo,
681 file_path: &str,
682 ctx: &ResolverContext,
683) -> (Option<String>, bool) {
684 let source = imp.source.trim();
685 if source.is_empty() {
686 return (None, true);
687 }
688 let source_rel = source.trim_start_matches("./").trim_start_matches('/');
689
690 let try_prefixes = |prefixes: &[&str]| -> Option<String> {
691 let mut candidates: Vec<String> = vec![source_rel.to_string()];
692 if !source_rel.ends_with(".sh") {
693 candidates.push(format!("{source_rel}.sh"));
694 }
695 for prefix in prefixes {
696 for c in candidates.iter() {
697 let p = format!("{prefix}{c}");
698 if ctx.file_exists(&p) {
699 return Some(p);
700 }
701 }
702 }
703 None
704 };
705
706 if source.starts_with('.') || source.starts_with('/') || source_rel.contains('/') {
707 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
708 let dir_prefix = if dir.as_os_str().is_empty() {
709 "".to_string()
710 } else {
711 format!("{}/", dir.to_string_lossy())
712 };
713 if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
714 return (Some(found), false);
715 }
716 if let Some(found) = try_prefixes(&["", "scripts/", "bin/"]) {
717 return (Some(found), false);
718 }
719 return (None, false);
720 }
721
722 (None, true)
723}
724
725fn resolve_dart(
730 imp: &ImportInfo,
731 file_path: &str,
732 ctx: &ResolverContext,
733) -> (Option<String>, bool) {
734 let source = imp.source.trim();
735 if source.is_empty() {
736 return (None, true);
737 }
738 if source.starts_with("dart:") {
739 return (None, true);
740 }
741
742 let try_prefixes = |prefixes: &[&str], rel: &str| -> Option<String> {
743 let rel = rel.trim_start_matches("./").trim_start_matches('/').trim();
744 let mut candidates: Vec<String> = vec![rel.to_string()];
745 if !rel.ends_with(".dart") {
746 candidates.push(format!("{rel}.dart"));
747 }
748 for prefix in prefixes {
749 for c in candidates.iter() {
750 let p = format!("{prefix}{c}");
751 if ctx.file_exists(&p) {
752 return Some(p);
753 }
754 }
755 }
756 None
757 };
758
759 if source.starts_with("package:") {
760 if let Some(pkg) = ctx.dart_package.as_deref() {
761 let prefix = format!("package:{pkg}/");
762 if let Some(rest) = source.strip_prefix(&prefix) {
763 if let Some(found) = try_prefixes(&["lib/", ""], rest) {
764 return (Some(found), false);
765 }
766 return (None, false);
767 }
768 }
769 return (None, true);
770 }
771
772 if source.starts_with('.') || source.starts_with('/') {
773 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
774 let dir_prefix = if dir.as_os_str().is_empty() {
775 "".to_string()
776 } else {
777 format!("{}/", dir.to_string_lossy())
778 };
779 if let Some(found) = try_prefixes(&[dir_prefix.as_str()], source) {
780 return (Some(found), false);
781 }
782 if let Some(found) = try_prefixes(&["", "lib/"], source) {
783 return (Some(found), false);
784 }
785 return (None, false);
786 }
787
788 (None, true)
789}
790
791fn resolve_zig(imp: &ImportInfo, file_path: &str, ctx: &ResolverContext) -> (Option<String>, bool) {
796 let source = imp.source.trim();
797 if source.is_empty() {
798 return (None, true);
799 }
800 let source_rel = source.trim_start_matches("./").trim_start_matches('/');
801 if source_rel == "std" {
802 return (None, true);
803 }
804
805 let try_prefixes = |prefixes: &[&str]| -> Option<String> {
806 let mut candidates: Vec<String> = vec![source_rel.to_string()];
807 if !source_rel.ends_with(".zig") {
808 candidates.push(format!("{source_rel}.zig"));
809 }
810 for prefix in prefixes {
811 for c in candidates.iter() {
812 let p = format!("{prefix}{c}");
813 if ctx.file_exists(&p) {
814 return Some(p);
815 }
816 }
817 }
818 None
819 };
820
821 if source.starts_with('.') || source_rel.contains('/') || source_rel.ends_with(".zig") {
822 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
823 let dir_prefix = if dir.as_os_str().is_empty() {
824 "".to_string()
825 } else {
826 format!("{}/", dir.to_string_lossy())
827 };
828 if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
829 return (Some(found), false);
830 }
831 if let Some(found) = try_prefixes(&["", "src/"]) {
832 return (Some(found), false);
833 }
834 return (None, false);
835 }
836
837 (None, true)
838}
839
840fn load_tsconfig_paths(root: &Path) -> HashMap<String, String> {
845 let mut paths = HashMap::new();
846
847 let candidates = ["tsconfig.json", "tsconfig.base.json", "jsconfig.json"];
848 for name in &candidates {
849 let tsconfig_path = root.join(name);
850 if let Ok(content) = std::fs::read_to_string(&tsconfig_path) {
851 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
852 if let Some(compiler) = json.get("compilerOptions") {
853 let base_url = compiler
854 .get("baseUrl")
855 .and_then(|v| v.as_str())
856 .unwrap_or(".");
857
858 if let Some(path_map) = compiler.get("paths").and_then(|v| v.as_object()) {
859 for (pattern, targets) in path_map {
860 if let Some(first_target) = targets
861 .as_array()
862 .and_then(|a| a.first())
863 .and_then(|v| v.as_str())
864 {
865 let resolved = if base_url == "." {
866 first_target.to_string()
867 } else {
868 format!("{base_url}/{first_target}")
869 };
870 paths.insert(pattern.clone(), resolved);
871 }
872 }
873 }
874 }
875 }
876 break;
877 }
878 }
879
880 paths
881}
882
883fn load_go_module(root: &Path) -> Option<String> {
884 let go_mod = root.join("go.mod");
885 let content = std::fs::read_to_string(go_mod).ok()?;
886 for line in content.lines() {
887 let trimmed = line.trim();
888 if trimmed.starts_with("module ") {
889 return Some(trimmed.strip_prefix("module ")?.trim().to_string());
890 }
891 }
892 None
893}
894
895fn load_dart_package(root: &Path) -> Option<String> {
896 let pubspec = root.join("pubspec.yaml");
897 let content = std::fs::read_to_string(pubspec).ok()?;
898 for line in content.lines() {
899 let trimmed = line.trim();
900 if let Some(rest) = trimmed.strip_prefix("name:") {
901 let name = rest.trim();
902 if !name.is_empty() {
903 return Some(name.to_string());
904 }
905 }
906 }
907 None
908}
909
910fn normalize_path(path: &Path) -> String {
915 let mut parts: Vec<&str> = Vec::new();
916 for component in path.components() {
917 match component {
918 std::path::Component::ParentDir => {
919 parts.pop();
920 }
921 std::path::Component::CurDir => {}
922 std::path::Component::Normal(s) => {
923 parts.push(s.to_str().unwrap_or(""));
924 }
925 _ => {}
926 }
927 }
928 parts.join("/")
929}
930
931#[cfg(test)]
936mod tests {
937 use super::*;
938 use crate::core::deep_queries::{ImportInfo, ImportKind};
939
940 fn make_ctx(files: &[&str]) -> ResolverContext {
941 ResolverContext {
942 project_root: PathBuf::from("/project"),
943 file_paths: files.iter().map(|s| s.to_string()).collect(),
944 tsconfig_paths: HashMap::new(),
945 go_module: None,
946 dart_package: None,
947 file_set: files.iter().map(|s| s.to_string()).collect(),
948 }
949 }
950
951 fn make_import(source: &str) -> ImportInfo {
952 ImportInfo {
953 source: source.to_string(),
954 names: Vec::new(),
955 kind: ImportKind::Named,
956 line: 1,
957 is_type_only: false,
958 }
959 }
960
961 #[test]
964 fn ts_relative_import() {
965 let ctx = make_ctx(&["src/components/Button.tsx", "src/utils/helpers.ts"]);
966 let imp = make_import("./helpers");
967 let results = resolve_imports(&[imp], "src/utils/index.ts", "ts", &ctx);
968 assert_eq!(
969 results[0].resolved_path.as_deref(),
970 Some("src/utils/helpers.ts")
971 );
972 assert!(!results[0].is_external);
973 }
974
975 #[test]
976 fn ts_relative_parent() {
977 let ctx = make_ctx(&["src/utils.ts", "src/components/Button.tsx"]);
978 let imp = make_import("../utils");
979 let results = resolve_imports(&[imp], "src/components/Button.tsx", "ts", &ctx);
980 assert_eq!(results[0].resolved_path.as_deref(), Some("src/utils.ts"));
981 }
982
983 #[test]
984 fn ts_index_file() {
985 let ctx = make_ctx(&["src/components/index.ts", "src/app.ts"]);
986 let imp = make_import("./components");
987 let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
988 assert_eq!(
989 results[0].resolved_path.as_deref(),
990 Some("src/components/index.ts")
991 );
992 }
993
994 #[test]
995 fn ts_external_package() {
996 let ctx = make_ctx(&["src/app.ts"]);
997 let imp = make_import("react");
998 let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
999 assert!(results[0].is_external);
1000 assert!(results[0].resolved_path.is_none());
1001 }
1002
1003 #[test]
1004 fn ts_tsconfig_paths() {
1005 let mut ctx = make_ctx(&["src/lib/utils/format.ts"]);
1006 ctx.tsconfig_paths
1007 .insert("@utils/*".to_string(), "src/lib/utils/*".to_string());
1008 let imp = make_import("@utils/format");
1009 let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
1010 assert_eq!(
1011 results[0].resolved_path.as_deref(),
1012 Some("src/lib/utils/format.ts")
1013 );
1014 assert!(!results[0].is_external);
1015 }
1016
1017 #[test]
1020 fn rust_crate_import() {
1021 let ctx = make_ctx(&["src/core/session.rs", "src/main.rs"]);
1022 let imp = make_import("crate::core::session");
1023 let results = resolve_imports(&[imp], "src/server.rs", "rs", &ctx);
1024 assert_eq!(
1025 results[0].resolved_path.as_deref(),
1026 Some("src/core/session.rs")
1027 );
1028 assert!(!results[0].is_external);
1029 }
1030
1031 #[test]
1032 fn rust_mod_rs() {
1033 let ctx = make_ctx(&["src/core/mod.rs", "src/main.rs"]);
1034 let imp = make_import("crate::core");
1035 let results = resolve_imports(&[imp], "src/main.rs", "rs", &ctx);
1036 assert_eq!(results[0].resolved_path.as_deref(), Some("src/core/mod.rs"));
1037 }
1038
1039 #[test]
1040 fn rust_external_crate() {
1041 let ctx = make_ctx(&["src/main.rs"]);
1042 let imp = make_import("anyhow::Result");
1043 let results = resolve_imports(&[imp], "src/main.rs", "rs", &ctx);
1044 assert!(results[0].is_external);
1045 }
1046
1047 #[test]
1048 fn rust_symbol_in_module() {
1049 let ctx = make_ctx(&["src/core/session.rs"]);
1050 let imp = make_import("crate::core::session::SessionState");
1051 let results = resolve_imports(&[imp], "src/server.rs", "rs", &ctx);
1052 assert_eq!(
1053 results[0].resolved_path.as_deref(),
1054 Some("src/core/session.rs")
1055 );
1056 }
1057
1058 #[test]
1061 fn python_absolute_import() {
1062 let ctx = make_ctx(&["models/user.py", "app.py"]);
1063 let imp = make_import("models.user");
1064 let results = resolve_imports(&[imp], "app.py", "py", &ctx);
1065 assert_eq!(results[0].resolved_path.as_deref(), Some("models/user.py"));
1066 }
1067
1068 #[test]
1069 fn python_package_init() {
1070 let ctx = make_ctx(&["utils/__init__.py", "app.py"]);
1071 let imp = make_import("utils");
1072 let results = resolve_imports(&[imp], "app.py", "py", &ctx);
1073 assert_eq!(
1074 results[0].resolved_path.as_deref(),
1075 Some("utils/__init__.py")
1076 );
1077 }
1078
1079 #[test]
1080 fn python_relative_import() {
1081 let ctx = make_ctx(&["pkg/utils.py", "pkg/main.py"]);
1082 let imp = make_import(".utils");
1083 let results = resolve_imports(&[imp], "pkg/main.py", "py", &ctx);
1084 assert_eq!(results[0].resolved_path.as_deref(), Some("pkg/utils.py"));
1085 }
1086
1087 #[test]
1088 fn python_stdlib() {
1089 let ctx = make_ctx(&["app.py"]);
1090 let imp = make_import("os");
1091 let results = resolve_imports(&[imp], "app.py", "py", &ctx);
1092 assert!(results[0].is_external);
1093 }
1094
1095 #[test]
1098 fn go_internal_package() {
1099 let mut ctx = make_ctx(&["cmd/server/main.go", "internal/auth/auth.go"]);
1100 ctx.go_module = Some("github.com/org/project".to_string());
1101 let imp = make_import("github.com/org/project/internal/auth");
1102 let results = resolve_imports(&[imp], "cmd/server/main.go", "go", &ctx);
1103 assert_eq!(results[0].resolved_path.as_deref(), Some("internal/auth"));
1104 assert!(!results[0].is_external);
1105 }
1106
1107 #[test]
1108 fn go_external_package() {
1109 let ctx = make_ctx(&["main.go"]);
1110 let imp = make_import("fmt");
1111 let results = resolve_imports(&[imp], "main.go", "go", &ctx);
1112 assert!(results[0].is_external);
1113 }
1114
1115 #[test]
1118 fn java_internal_class() {
1119 let ctx = make_ctx(&[
1120 "src/main/java/com/example/service/UserService.java",
1121 "src/main/java/com/example/model/User.java",
1122 ]);
1123 let imp = make_import("com.example.model.User");
1124 let results = resolve_imports(
1125 &[imp],
1126 "src/main/java/com/example/service/UserService.java",
1127 "java",
1128 &ctx,
1129 );
1130 assert_eq!(
1131 results[0].resolved_path.as_deref(),
1132 Some("src/main/java/com/example/model/User.java")
1133 );
1134 assert!(!results[0].is_external);
1135 }
1136
1137 #[test]
1138 fn java_stdlib() {
1139 let ctx = make_ctx(&["Main.java"]);
1140 let imp = make_import("java.util.List");
1141 let results = resolve_imports(&[imp], "Main.java", "java", &ctx);
1142 assert!(results[0].is_external);
1143 }
1144
1145 #[test]
1148 fn empty_imports() {
1149 let ctx = make_ctx(&["src/main.rs"]);
1150 let results = resolve_imports(&[], "src/main.rs", "rs", &ctx);
1151 assert!(results.is_empty());
1152 }
1153
1154 #[test]
1155 fn unsupported_language() {
1156 let ctx = make_ctx(&["main.rb"]);
1157 let imp = make_import("some_module");
1158 let results = resolve_imports(&[imp], "main.rb", "rb", &ctx);
1159 assert!(results[0].is_external);
1160 }
1161
1162 #[test]
1163 fn c_include_resolves_from_include_dir() {
1164 let ctx = make_ctx(&["include/foo/bar.h", "src/main.c"]);
1165 let imp = make_import("foo/bar.h");
1166 let results = resolve_imports(&[imp], "src/main.c", "c", &ctx);
1167 assert_eq!(
1168 results[0].resolved_path.as_deref(),
1169 Some("include/foo/bar.h")
1170 );
1171 assert!(!results[0].is_external);
1172 }
1173
1174 #[test]
1175 fn ruby_require_relative_resolves() {
1176 let ctx = make_ctx(&["lib/utils.rb", "app.rb"]);
1177 let imp = make_import("./lib/utils");
1178 let results = resolve_imports(&[imp], "app.rb", "rb", &ctx);
1179 assert_eq!(results[0].resolved_path.as_deref(), Some("lib/utils.rb"));
1180 assert!(!results[0].is_external);
1181 }
1182
1183 #[test]
1184 fn php_require_resolves() {
1185 let ctx = make_ctx(&["vendor/autoload.php", "index.php"]);
1186 let imp = make_import("./vendor/autoload.php");
1187 let results = resolve_imports(&[imp], "index.php", "php", &ctx);
1188 assert_eq!(
1189 results[0].resolved_path.as_deref(),
1190 Some("vendor/autoload.php")
1191 );
1192 assert!(!results[0].is_external);
1193 }
1194
1195 #[test]
1196 fn bash_source_resolves() {
1197 let ctx = make_ctx(&["scripts/env.sh", "main.sh"]);
1198 let imp = make_import("./scripts/env.sh");
1199 let results = resolve_imports(&[imp], "main.sh", "sh", &ctx);
1200 assert_eq!(results[0].resolved_path.as_deref(), Some("scripts/env.sh"));
1201 assert!(!results[0].is_external);
1202 }
1203
1204 #[test]
1205 fn dart_package_import_resolves_to_lib() {
1206 let mut ctx = make_ctx(&["lib/src/util.dart", "lib/app.dart"]);
1207 ctx.dart_package = Some("myapp".to_string());
1208 let imp = make_import("package:myapp/src/util.dart");
1209 let results = resolve_imports(&[imp], "lib/app.dart", "dart", &ctx);
1210 assert_eq!(
1211 results[0].resolved_path.as_deref(),
1212 Some("lib/src/util.dart")
1213 );
1214 assert!(!results[0].is_external);
1215 }
1216
1217 #[test]
1218 fn kotlin_import_resolves_to_src_main_kotlin() {
1219 let ctx = make_ctx(&[
1220 "src/main/kotlin/com/example/service/UserService.kt",
1221 "src/main/kotlin/com/example/App.kt",
1222 ]);
1223 let imp = make_import("com.example.service.UserService");
1224 let results = resolve_imports(&[imp], "src/main/kotlin/com/example/App.kt", "kt", &ctx);
1225 assert_eq!(
1226 results[0].resolved_path.as_deref(),
1227 Some("src/main/kotlin/com/example/service/UserService.kt")
1228 );
1229 assert!(!results[0].is_external);
1230 }
1231
1232 #[test]
1233 fn kotlin_stdlib_import_is_external() {
1234 let ctx = make_ctx(&["src/main/kotlin/App.kt"]);
1235 let imp = make_import("kotlin.collections.List");
1236 let results = resolve_imports(&[imp], "src/main/kotlin/App.kt", "kt", &ctx);
1237 assert!(results[0].is_external);
1238 }
1239
1240 #[test]
1241 fn kotlin_import_resolves_java_file() {
1242 let ctx = make_ctx(&[
1243 "src/main/java/com/example/LegacyUtil.java",
1244 "src/main/kotlin/com/example/App.kt",
1245 ]);
1246 let imp = make_import("com.example.LegacyUtil");
1247 let results = resolve_imports(&[imp], "src/main/kotlin/com/example/App.kt", "kt", &ctx);
1248 assert_eq!(
1249 results[0].resolved_path.as_deref(),
1250 Some("src/main/java/com/example/LegacyUtil.java")
1251 );
1252 assert!(!results[0].is_external);
1253 }
1254}