1use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14use super::deep_queries::ImportInfo;
15
16#[derive(Debug, Clone)]
17pub struct ResolvedImport {
18 pub source: String,
19 pub resolved_path: Option<String>,
20 pub is_external: bool,
21 pub line: usize,
22}
23
24#[derive(Debug)]
25pub struct ResolverContext {
26 pub project_root: PathBuf,
27 pub file_paths: Vec<String>,
28 pub tsconfig_paths: HashMap<String, String>,
29 pub go_module: Option<String>,
30 file_set: std::collections::HashSet<String>,
31}
32
33impl ResolverContext {
34 pub fn new(project_root: &Path, file_paths: Vec<String>) -> Self {
35 let file_set: std::collections::HashSet<String> = file_paths.iter().cloned().collect();
36
37 let tsconfig_paths = load_tsconfig_paths(project_root);
38 let go_module = load_go_module(project_root);
39
40 Self {
41 project_root: project_root.to_path_buf(),
42 file_paths,
43 tsconfig_paths,
44 go_module,
45 file_set,
46 }
47 }
48
49 fn file_exists(&self, rel_path: &str) -> bool {
50 self.file_set.contains(rel_path)
51 }
52}
53
54pub fn resolve_imports(
55 imports: &[ImportInfo],
56 file_path: &str,
57 ext: &str,
58 ctx: &ResolverContext,
59) -> Vec<ResolvedImport> {
60 imports
61 .iter()
62 .map(|imp| {
63 let (resolved, is_external) = resolve_one(imp, file_path, ext, ctx);
64 ResolvedImport {
65 source: imp.source.clone(),
66 resolved_path: resolved,
67 is_external,
68 line: imp.line,
69 }
70 })
71 .collect()
72}
73
74fn resolve_one(
75 imp: &ImportInfo,
76 file_path: &str,
77 ext: &str,
78 ctx: &ResolverContext,
79) -> (Option<String>, bool) {
80 match ext {
81 "ts" | "tsx" | "js" | "jsx" => resolve_ts(imp, file_path, ctx),
82 "rs" => resolve_rust(imp, file_path, ctx),
83 "py" => resolve_python(imp, file_path, ctx),
84 "go" => resolve_go(imp, ctx),
85 "java" => resolve_java(imp, ctx),
86 _ => (None, true),
87 }
88}
89
90fn resolve_ts(imp: &ImportInfo, file_path: &str, ctx: &ResolverContext) -> (Option<String>, bool) {
95 let source = &imp.source;
96
97 if source.starts_with('.') {
98 let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
99 let resolved = dir.join(source);
100 let normalized = normalize_path(&resolved);
101
102 if let Some(found) = try_ts_extensions(&normalized, ctx) {
103 return (Some(found), false);
104 }
105 return (None, false);
106 }
107
108 if let Some(mapped) = resolve_tsconfig_path(source, ctx) {
109 return (Some(mapped), false);
110 }
111
112 (None, true)
113}
114
115fn try_ts_extensions(base: &str, ctx: &ResolverContext) -> Option<String> {
116 let extensions = [".ts", ".tsx", ".js", ".jsx", ".d.ts"];
117
118 if ctx.file_exists(base) {
119 return Some(base.to_string());
120 }
121
122 for ext in &extensions {
123 let with_ext = format!("{base}{ext}");
124 if ctx.file_exists(&with_ext) {
125 return Some(with_ext);
126 }
127 }
128
129 let index_extensions = ["index.ts", "index.tsx", "index.js", "index.jsx"];
130 for idx in &index_extensions {
131 let index_path = format!("{base}/{idx}");
132 if ctx.file_exists(&index_path) {
133 return Some(index_path);
134 }
135 }
136
137 None
138}
139
140fn resolve_tsconfig_path(source: &str, ctx: &ResolverContext) -> Option<String> {
141 for (pattern, target) in &ctx.tsconfig_paths {
142 let prefix = pattern.trim_end_matches('*');
143 if let Some(remainder) = source.strip_prefix(prefix) {
144 let target_base = target.trim_end_matches('*');
145 let candidate = format!("{target_base}{remainder}");
146 if let Some(found) = try_ts_extensions(&candidate, ctx) {
147 return Some(found);
148 }
149 }
150 }
151 None
152}
153
154fn resolve_rust(
159 imp: &ImportInfo,
160 file_path: &str,
161 ctx: &ResolverContext,
162) -> (Option<String>, bool) {
163 let source = &imp.source;
164
165 if source.starts_with("crate::")
166 || source.starts_with("super::")
167 || source.starts_with("self::")
168 {
169 let cleaned = source.replace("crate::", "").replace("self::", "");
170
171 let resolved = if source.starts_with("super::") {
172 let dir = Path::new(file_path).parent().and_then(|p| p.parent());
173 match dir {
174 Some(d) => {
175 let rest = source.trim_start_matches("super::");
176 d.join(rest.replace("::", "/"))
177 .to_string_lossy()
178 .to_string()
179 }
180 None => cleaned.replace("::", "/"),
181 }
182 } else {
183 cleaned.replace("::", "/")
184 };
185
186 if let Some(found) = try_rust_paths(&resolved, ctx) {
187 return (Some(found), false);
188 }
189 return (None, false);
190 }
191
192 let parts: Vec<&str> = source.split("::").collect();
193 if parts.is_empty() {
194 return (None, true);
195 }
196
197 let is_external = !source.starts_with("crate")
198 && !ctx.file_paths.iter().any(|f| {
199 let stem = Path::new(f)
200 .file_stem()
201 .and_then(|s| s.to_str())
202 .unwrap_or("");
203 stem == parts[0]
204 });
205
206 if is_external {
207 return (None, true);
208 }
209
210 let as_path = source.replace("::", "/");
211 if let Some(found) = try_rust_paths(&as_path, ctx) {
212 return (Some(found), false);
213 }
214
215 (None, is_external)
216}
217
218fn try_rust_paths(base: &str, ctx: &ResolverContext) -> Option<String> {
219 let prefixes = ["", "src/", "rust/src/"];
220 for prefix in &prefixes {
221 let candidate = format!("{prefix}{base}.rs");
222 if ctx.file_exists(&candidate) {
223 return Some(candidate);
224 }
225 let mod_candidate = format!("{prefix}{base}/mod.rs");
226 if ctx.file_exists(&mod_candidate) {
227 return Some(mod_candidate);
228 }
229 }
230
231 let parts: Vec<&str> = base.rsplitn(2, '/').collect();
232 if parts.len() == 2 {
233 let parent = parts[1];
234 for prefix in &prefixes {
235 let candidate = format!("{prefix}{parent}.rs");
236 if ctx.file_exists(&candidate) {
237 return Some(candidate);
238 }
239 }
240 }
241
242 None
243}
244
245fn resolve_python(
250 imp: &ImportInfo,
251 file_path: &str,
252 ctx: &ResolverContext,
253) -> (Option<String>, bool) {
254 let source = &imp.source;
255
256 if source.starts_with('.') {
257 let dot_count = source.chars().take_while(|c| *c == '.').count();
258 let module_part = &source[dot_count..];
259
260 let mut dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
261 for _ in 1..dot_count {
262 dir = dir.parent().unwrap_or(Path::new(""));
263 }
264
265 let as_path = module_part.replace('.', "/");
266 let base = if as_path.is_empty() {
267 dir.to_string_lossy().to_string()
268 } else {
269 format!("{}/{as_path}", dir.display())
270 };
271
272 if let Some(found) = try_python_paths(&base, ctx) {
273 return (Some(found), false);
274 }
275 return (None, false);
276 }
277
278 let as_path = source.replace('.', "/");
279
280 if let Some(found) = try_python_paths(&as_path, ctx) {
281 return (Some(found), false);
282 }
283
284 let is_stdlib = is_python_stdlib(source);
285 (
286 None,
287 is_stdlib || !ctx.file_paths.iter().any(|f| f.contains(&as_path)),
288 )
289}
290
291fn try_python_paths(base: &str, ctx: &ResolverContext) -> Option<String> {
292 let py_file = format!("{base}.py");
293 if ctx.file_exists(&py_file) {
294 return Some(py_file);
295 }
296
297 let init_file = format!("{base}/__init__.py");
298 if ctx.file_exists(&init_file) {
299 return Some(init_file);
300 }
301
302 let prefixes = ["src/", "lib/"];
303 for prefix in &prefixes {
304 let candidate = format!("{prefix}{base}.py");
305 if ctx.file_exists(&candidate) {
306 return Some(candidate);
307 }
308 let init = format!("{prefix}{base}/__init__.py");
309 if ctx.file_exists(&init) {
310 return Some(init);
311 }
312 }
313
314 None
315}
316
317fn is_python_stdlib(module: &str) -> bool {
318 let first = module.split('.').next().unwrap_or(module);
319 matches!(
320 first,
321 "os" | "sys"
322 | "json"
323 | "re"
324 | "math"
325 | "datetime"
326 | "typing"
327 | "collections"
328 | "itertools"
329 | "functools"
330 | "pathlib"
331 | "io"
332 | "abc"
333 | "enum"
334 | "dataclasses"
335 | "logging"
336 | "unittest"
337 | "argparse"
338 | "subprocess"
339 | "threading"
340 | "multiprocessing"
341 | "socket"
342 | "http"
343 | "urllib"
344 | "hashlib"
345 | "hmac"
346 | "secrets"
347 | "time"
348 | "copy"
349 | "pprint"
350 | "textwrap"
351 | "shutil"
352 | "tempfile"
353 | "glob"
354 | "fnmatch"
355 | "contextlib"
356 | "inspect"
357 | "importlib"
358 | "pickle"
359 | "shelve"
360 | "csv"
361 | "configparser"
362 | "struct"
363 | "codecs"
364 | "string"
365 | "difflib"
366 | "ast"
367 | "dis"
368 | "traceback"
369 | "warnings"
370 | "concurrent"
371 | "asyncio"
372 | "signal"
373 | "select"
374 )
375}
376
377fn resolve_go(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
382 let source = &imp.source;
383
384 if let Some(ref go_mod) = ctx.go_module {
385 if source.starts_with(go_mod.as_str()) {
386 let relative = source.strip_prefix(go_mod.as_str()).unwrap_or(source);
387 let relative = relative.trim_start_matches('/');
388
389 if let Some(found) = try_go_package(relative, ctx) {
390 return (Some(found), false);
391 }
392 return (None, false);
393 }
394 }
395
396 if let Some(found) = try_go_package(source, ctx) {
397 return (Some(found), false);
398 }
399
400 (None, true)
401}
402
403fn try_go_package(pkg_path: &str, ctx: &ResolverContext) -> Option<String> {
404 for file in &ctx.file_paths {
405 if file.ends_with(".go") {
406 let dir = Path::new(file).parent()?.to_string_lossy();
407 if dir == pkg_path || dir.ends_with(pkg_path) {
408 return Some(dir.to_string());
409 }
410 }
411 }
412 None
413}
414
415fn resolve_java(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
420 let source = &imp.source;
421
422 if source.starts_with("java.") || source.starts_with("javax.") || source.starts_with("sun.") {
423 return (None, true);
424 }
425
426 let parts: Vec<&str> = source.rsplitn(2, '.').collect();
427 if parts.len() < 2 {
428 return (None, true);
429 }
430
431 let class_name = parts[0];
432 let package_path = parts[1].replace('.', "/");
433 let file_path = format!("{package_path}/{class_name}.java");
434
435 let search_roots = ["", "src/main/java/", "src/", "app/src/main/java/"];
436 for root in &search_roots {
437 let candidate = format!("{root}{file_path}");
438 if ctx.file_exists(&candidate) {
439 return (Some(candidate), false);
440 }
441 }
442
443 (
444 None,
445 !ctx.file_paths.iter().any(|f| f.contains(&package_path)),
446 )
447}
448
449fn load_tsconfig_paths(root: &Path) -> HashMap<String, String> {
454 let mut paths = HashMap::new();
455
456 let candidates = ["tsconfig.json", "tsconfig.base.json", "jsconfig.json"];
457 for name in &candidates {
458 let tsconfig_path = root.join(name);
459 if let Ok(content) = std::fs::read_to_string(&tsconfig_path) {
460 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
461 if let Some(compiler) = json.get("compilerOptions") {
462 let base_url = compiler
463 .get("baseUrl")
464 .and_then(|v| v.as_str())
465 .unwrap_or(".");
466
467 if let Some(path_map) = compiler.get("paths").and_then(|v| v.as_object()) {
468 for (pattern, targets) in path_map {
469 if let Some(first_target) = targets
470 .as_array()
471 .and_then(|a| a.first())
472 .and_then(|v| v.as_str())
473 {
474 let resolved = if base_url == "." {
475 first_target.to_string()
476 } else {
477 format!("{base_url}/{first_target}")
478 };
479 paths.insert(pattern.clone(), resolved);
480 }
481 }
482 }
483 }
484 }
485 break;
486 }
487 }
488
489 paths
490}
491
492fn load_go_module(root: &Path) -> Option<String> {
493 let go_mod = root.join("go.mod");
494 let content = std::fs::read_to_string(go_mod).ok()?;
495 for line in content.lines() {
496 let trimmed = line.trim();
497 if trimmed.starts_with("module ") {
498 return Some(trimmed.strip_prefix("module ")?.trim().to_string());
499 }
500 }
501 None
502}
503
504fn normalize_path(path: &Path) -> String {
509 let mut parts: Vec<&str> = Vec::new();
510 for component in path.components() {
511 match component {
512 std::path::Component::ParentDir => {
513 parts.pop();
514 }
515 std::path::Component::CurDir => {}
516 std::path::Component::Normal(s) => {
517 parts.push(s.to_str().unwrap_or(""));
518 }
519 _ => {}
520 }
521 }
522 parts.join("/")
523}
524
525#[cfg(test)]
530mod tests {
531 use super::*;
532 use crate::core::deep_queries::{ImportInfo, ImportKind};
533
534 fn make_ctx(files: &[&str]) -> ResolverContext {
535 ResolverContext {
536 project_root: PathBuf::from("/project"),
537 file_paths: files.iter().map(|s| s.to_string()).collect(),
538 tsconfig_paths: HashMap::new(),
539 go_module: None,
540 file_set: files.iter().map(|s| s.to_string()).collect(),
541 }
542 }
543
544 fn make_import(source: &str) -> ImportInfo {
545 ImportInfo {
546 source: source.to_string(),
547 names: Vec::new(),
548 kind: ImportKind::Named,
549 line: 1,
550 is_type_only: false,
551 }
552 }
553
554 #[test]
557 fn ts_relative_import() {
558 let ctx = make_ctx(&["src/components/Button.tsx", "src/utils/helpers.ts"]);
559 let imp = make_import("./helpers");
560 let results = resolve_imports(&[imp], "src/utils/index.ts", "ts", &ctx);
561 assert_eq!(
562 results[0].resolved_path.as_deref(),
563 Some("src/utils/helpers.ts")
564 );
565 assert!(!results[0].is_external);
566 }
567
568 #[test]
569 fn ts_relative_parent() {
570 let ctx = make_ctx(&["src/utils.ts", "src/components/Button.tsx"]);
571 let imp = make_import("../utils");
572 let results = resolve_imports(&[imp], "src/components/Button.tsx", "ts", &ctx);
573 assert_eq!(results[0].resolved_path.as_deref(), Some("src/utils.ts"));
574 }
575
576 #[test]
577 fn ts_index_file() {
578 let ctx = make_ctx(&["src/components/index.ts", "src/app.ts"]);
579 let imp = make_import("./components");
580 let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
581 assert_eq!(
582 results[0].resolved_path.as_deref(),
583 Some("src/components/index.ts")
584 );
585 }
586
587 #[test]
588 fn ts_external_package() {
589 let ctx = make_ctx(&["src/app.ts"]);
590 let imp = make_import("react");
591 let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
592 assert!(results[0].is_external);
593 assert!(results[0].resolved_path.is_none());
594 }
595
596 #[test]
597 fn ts_tsconfig_paths() {
598 let mut ctx = make_ctx(&["src/lib/utils/format.ts"]);
599 ctx.tsconfig_paths
600 .insert("@utils/*".to_string(), "src/lib/utils/*".to_string());
601 let imp = make_import("@utils/format");
602 let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
603 assert_eq!(
604 results[0].resolved_path.as_deref(),
605 Some("src/lib/utils/format.ts")
606 );
607 assert!(!results[0].is_external);
608 }
609
610 #[test]
613 fn rust_crate_import() {
614 let ctx = make_ctx(&["src/core/session.rs", "src/main.rs"]);
615 let imp = make_import("crate::core::session");
616 let results = resolve_imports(&[imp], "src/server.rs", "rs", &ctx);
617 assert_eq!(
618 results[0].resolved_path.as_deref(),
619 Some("src/core/session.rs")
620 );
621 assert!(!results[0].is_external);
622 }
623
624 #[test]
625 fn rust_mod_rs() {
626 let ctx = make_ctx(&["src/core/mod.rs", "src/main.rs"]);
627 let imp = make_import("crate::core");
628 let results = resolve_imports(&[imp], "src/main.rs", "rs", &ctx);
629 assert_eq!(results[0].resolved_path.as_deref(), Some("src/core/mod.rs"));
630 }
631
632 #[test]
633 fn rust_external_crate() {
634 let ctx = make_ctx(&["src/main.rs"]);
635 let imp = make_import("anyhow::Result");
636 let results = resolve_imports(&[imp], "src/main.rs", "rs", &ctx);
637 assert!(results[0].is_external);
638 }
639
640 #[test]
641 fn rust_symbol_in_module() {
642 let ctx = make_ctx(&["src/core/session.rs"]);
643 let imp = make_import("crate::core::session::SessionState");
644 let results = resolve_imports(&[imp], "src/server.rs", "rs", &ctx);
645 assert_eq!(
646 results[0].resolved_path.as_deref(),
647 Some("src/core/session.rs")
648 );
649 }
650
651 #[test]
654 fn python_absolute_import() {
655 let ctx = make_ctx(&["models/user.py", "app.py"]);
656 let imp = make_import("models.user");
657 let results = resolve_imports(&[imp], "app.py", "py", &ctx);
658 assert_eq!(results[0].resolved_path.as_deref(), Some("models/user.py"));
659 }
660
661 #[test]
662 fn python_package_init() {
663 let ctx = make_ctx(&["utils/__init__.py", "app.py"]);
664 let imp = make_import("utils");
665 let results = resolve_imports(&[imp], "app.py", "py", &ctx);
666 assert_eq!(
667 results[0].resolved_path.as_deref(),
668 Some("utils/__init__.py")
669 );
670 }
671
672 #[test]
673 fn python_relative_import() {
674 let ctx = make_ctx(&["pkg/utils.py", "pkg/main.py"]);
675 let imp = make_import(".utils");
676 let results = resolve_imports(&[imp], "pkg/main.py", "py", &ctx);
677 assert_eq!(results[0].resolved_path.as_deref(), Some("pkg/utils.py"));
678 }
679
680 #[test]
681 fn python_stdlib() {
682 let ctx = make_ctx(&["app.py"]);
683 let imp = make_import("os");
684 let results = resolve_imports(&[imp], "app.py", "py", &ctx);
685 assert!(results[0].is_external);
686 }
687
688 #[test]
691 fn go_internal_package() {
692 let mut ctx = make_ctx(&["cmd/server/main.go", "internal/auth/auth.go"]);
693 ctx.go_module = Some("github.com/org/project".to_string());
694 let imp = make_import("github.com/org/project/internal/auth");
695 let results = resolve_imports(&[imp], "cmd/server/main.go", "go", &ctx);
696 assert_eq!(results[0].resolved_path.as_deref(), Some("internal/auth"));
697 assert!(!results[0].is_external);
698 }
699
700 #[test]
701 fn go_external_package() {
702 let ctx = make_ctx(&["main.go"]);
703 let imp = make_import("fmt");
704 let results = resolve_imports(&[imp], "main.go", "go", &ctx);
705 assert!(results[0].is_external);
706 }
707
708 #[test]
711 fn java_internal_class() {
712 let ctx = make_ctx(&[
713 "src/main/java/com/example/service/UserService.java",
714 "src/main/java/com/example/model/User.java",
715 ]);
716 let imp = make_import("com.example.model.User");
717 let results = resolve_imports(
718 &[imp],
719 "src/main/java/com/example/service/UserService.java",
720 "java",
721 &ctx,
722 );
723 assert_eq!(
724 results[0].resolved_path.as_deref(),
725 Some("src/main/java/com/example/model/User.java")
726 );
727 assert!(!results[0].is_external);
728 }
729
730 #[test]
731 fn java_stdlib() {
732 let ctx = make_ctx(&["Main.java"]);
733 let imp = make_import("java.util.List");
734 let results = resolve_imports(&[imp], "Main.java", "java", &ctx);
735 assert!(results[0].is_external);
736 }
737
738 #[test]
741 fn empty_imports() {
742 let ctx = make_ctx(&["src/main.rs"]);
743 let results = resolve_imports(&[], "src/main.rs", "rs", &ctx);
744 assert!(results.is_empty());
745 }
746
747 #[test]
748 fn unsupported_language() {
749 let ctx = make_ctx(&["main.rb"]);
750 let imp = make_import("some_module");
751 let results = resolve_imports(&[imp], "main.rb", "rb", &ctx);
752 assert!(results[0].is_external);
753 }
754}