Skip to main content

lean_ctx/core/
import_resolver.rs

1//! Import-to-file resolution (AST-driven import strings → project paths).
2//!
3//! Resolves import strings from `deep_queries::ImportInfo` to actual file paths
4//! within a project. Handles language-specific module systems:
5//! - TypeScript/JavaScript: relative paths, index files, package.json, tsconfig paths
6//! - Python: dotted modules, __init__.py, relative imports
7//! - Rust: crate/super/self resolution, mod.rs
8//! - Go: go.mod module path, package = directory
9//! - Java: package-to-directory mapping
10//! - C/C++: local includes (best-effort)
11//! - Ruby: require_relative (best-effort)
12//! - PHP: include/require (best-effort)
13//! - Bash: source/. (best-effort)
14//! - Dart: relative + package:<name>/ (best-effort)
15//! - Zig: @import("path.zig") (best-effort)
16
17use 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
108// ---------------------------------------------------------------------------
109// TypeScript / JavaScript
110// ---------------------------------------------------------------------------
111
112fn 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
172// ---------------------------------------------------------------------------
173// Rust
174// ---------------------------------------------------------------------------
175
176fn 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
263// ---------------------------------------------------------------------------
264// Python
265// ---------------------------------------------------------------------------
266
267fn 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
395// ---------------------------------------------------------------------------
396// Go
397// ---------------------------------------------------------------------------
398
399fn 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
433// ---------------------------------------------------------------------------
434// Java
435// ---------------------------------------------------------------------------
436
437fn 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
467// ---------------------------------------------------------------------------
468// Kotlin
469// ---------------------------------------------------------------------------
470
471fn 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
519// ---------------------------------------------------------------------------
520// C / C++
521// ---------------------------------------------------------------------------
522
523fn 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
576// ---------------------------------------------------------------------------
577// Ruby
578// ---------------------------------------------------------------------------
579
580fn 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
626// ---------------------------------------------------------------------------
627// PHP
628// ---------------------------------------------------------------------------
629
630fn 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
675// ---------------------------------------------------------------------------
676// Bash
677// ---------------------------------------------------------------------------
678
679fn 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
725// ---------------------------------------------------------------------------
726// Dart
727// ---------------------------------------------------------------------------
728
729fn 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
791// ---------------------------------------------------------------------------
792// Zig
793// ---------------------------------------------------------------------------
794
795fn 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
840// ---------------------------------------------------------------------------
841// Config Loaders
842// ---------------------------------------------------------------------------
843
844fn 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
910// ---------------------------------------------------------------------------
911// Helpers
912// ---------------------------------------------------------------------------
913
914fn 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// ---------------------------------------------------------------------------
932// Tests
933// ---------------------------------------------------------------------------
934
935#[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    // --- TypeScript ---
962
963    #[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    // --- Rust ---
1018
1019    #[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    // --- Python ---
1059
1060    #[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    // --- Go ---
1096
1097    #[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    // --- Java ---
1116
1117    #[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    // --- Edge cases ---
1146
1147    #[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}