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