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