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 file.ends_with(".go") {
449            let dir = Path::new(file).parent()?.to_string_lossy();
450            if dir == pkg_path || dir.ends_with(pkg_path) {
451                return Some(dir.to_string());
452            }
453        }
454    }
455    None
456}
457
458// ---------------------------------------------------------------------------
459// Java
460// ---------------------------------------------------------------------------
461
462fn resolve_java(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
463    let source = &imp.source;
464
465    if source.starts_with("java.") || source.starts_with("javax.") || source.starts_with("sun.") {
466        return (None, true);
467    }
468
469    let parts: Vec<&str> = source.rsplitn(2, '.').collect();
470    if parts.len() < 2 {
471        return (None, true);
472    }
473
474    let class_name = parts[0];
475    let package_path = parts[1].replace('.', "/");
476    let file_path = format!("{package_path}/{class_name}.java");
477
478    let search_roots = ["", "src/main/java/", "src/", "app/src/main/java/"];
479    for root in &search_roots {
480        let candidate = format!("{root}{file_path}");
481        if ctx.file_exists(&candidate) {
482            return (Some(candidate), false);
483        }
484    }
485
486    (
487        None,
488        !ctx.file_paths.iter().any(|f| f.contains(&package_path)),
489    )
490}
491
492// ---------------------------------------------------------------------------
493// Kotlin
494// ---------------------------------------------------------------------------
495
496fn resolve_kotlin(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
497    let source = &imp.source;
498
499    if source.starts_with("java.")
500        || source.starts_with("javax.")
501        || source.starts_with("kotlin.")
502        || source.starts_with("kotlinx.")
503        || source.starts_with("android.")
504        || source.starts_with("androidx.")
505        || source.starts_with("org.junit.")
506        || source.starts_with("org.jetbrains.")
507    {
508        return (None, true);
509    }
510
511    let parts: Vec<&str> = source.rsplitn(2, '.').collect();
512    if parts.len() < 2 {
513        return (None, true);
514    }
515
516    let class_name = parts[0];
517    let package_path = parts[1].replace('.', "/");
518
519    let search_roots = [
520        "",
521        "src/main/kotlin/",
522        "src/main/java/",
523        "src/",
524        "app/src/main/kotlin/",
525        "app/src/main/java/",
526        "src/commonMain/kotlin/",
527    ];
528
529    for root in &search_roots {
530        for ext in &["kt", "kts", "java"] {
531            let candidate = format!("{root}{package_path}/{class_name}.{ext}");
532            if ctx.file_exists(&candidate) {
533                return (Some(candidate), false);
534            }
535        }
536    }
537
538    (
539        None,
540        !ctx.file_paths.iter().any(|f| f.contains(&package_path)),
541    )
542}
543
544// ---------------------------------------------------------------------------
545// C / C++
546// ---------------------------------------------------------------------------
547
548fn resolve_c_like(
549    imp: &ImportInfo,
550    file_path: &str,
551    ctx: &ResolverContext,
552) -> (Option<String>, bool) {
553    let source = imp.source.trim();
554    if source.is_empty() {
555        return (None, true);
556    }
557
558    let try_prefixes = |prefixes: &[&str], rel: &str| -> Option<String> {
559        let rel = rel.trim_start_matches("./").trim_start_matches('/');
560        let mut candidates: Vec<String> = vec![rel.to_string()];
561        for ext in [".h", ".hpp", ".c", ".cpp"] {
562            if !rel.ends_with(ext) {
563                candidates.push(format!("{rel}{ext}"));
564            }
565        }
566        for prefix in prefixes {
567            for c in candidates.iter() {
568                let p = format!("{prefix}{c}");
569                if ctx.file_exists(&p) {
570                    return Some(p);
571                }
572            }
573        }
574        None
575    };
576
577    if source.starts_with('.') {
578        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
579        let dir_prefix = if dir.as_os_str().is_empty() {
580            "".to_string()
581        } else {
582            format!("{}/", dir.to_string_lossy())
583        };
584        if let Some(found) = try_prefixes(&[dir_prefix.as_str()], source) {
585            return (Some(found), false);
586        }
587        return (None, false);
588    }
589
590    if ctx.file_exists(source) {
591        return (Some(source.to_string()), false);
592    }
593
594    if let Some(found) = try_prefixes(&["", "include/", "src/"], source) {
595        return (Some(found), false);
596    }
597
598    (None, true)
599}
600
601// ---------------------------------------------------------------------------
602// Ruby
603// ---------------------------------------------------------------------------
604
605fn resolve_ruby(
606    imp: &ImportInfo,
607    file_path: &str,
608    ctx: &ResolverContext,
609) -> (Option<String>, bool) {
610    let source = imp.source.trim();
611    if source.is_empty() {
612        return (None, true);
613    }
614    let source_rel = source.trim_start_matches("./").trim_start_matches('/');
615
616    let try_prefixes = |prefixes: &[&str]| -> Option<String> {
617        let mut candidates: Vec<String> = vec![source_rel.to_string()];
618        if !source_rel.ends_with(".rb") {
619            candidates.push(format!("{source_rel}.rb"));
620        }
621        for prefix in prefixes {
622            for c in candidates.iter() {
623                let p = format!("{prefix}{c}");
624                if ctx.file_exists(&p) {
625                    return Some(p);
626                }
627            }
628        }
629        None
630    };
631
632    if source.starts_with('.') || source_rel.contains('/') {
633        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
634        let dir_prefix = if dir.as_os_str().is_empty() {
635            "".to_string()
636        } else {
637            format!("{}/", dir.to_string_lossy())
638        };
639        if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
640            return (Some(found), false);
641        }
642        if let Some(found) = try_prefixes(&["", "lib/", "src/"]) {
643            return (Some(found), false);
644        }
645        return (None, false);
646    }
647
648    (None, true)
649}
650
651// ---------------------------------------------------------------------------
652// PHP
653// ---------------------------------------------------------------------------
654
655fn resolve_php(imp: &ImportInfo, file_path: &str, ctx: &ResolverContext) -> (Option<String>, bool) {
656    let source = imp.source.trim();
657    if source.is_empty() {
658        return (None, true);
659    }
660    if source.starts_with("http://") || source.starts_with("https://") {
661        return (None, true);
662    }
663    let source_rel = source.trim_start_matches("./").trim_start_matches('/');
664
665    let try_prefixes = |prefixes: &[&str]| -> Option<String> {
666        let mut candidates: Vec<String> = vec![source_rel.to_string()];
667        if !source_rel.ends_with(".php") {
668            candidates.push(format!("{source_rel}.php"));
669        }
670        for prefix in prefixes {
671            for c in candidates.iter() {
672                let p = format!("{prefix}{c}");
673                if ctx.file_exists(&p) {
674                    return Some(p);
675                }
676            }
677        }
678        None
679    };
680
681    if source.starts_with('.') || source.starts_with('/') || source_rel.contains('/') {
682        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
683        let dir_prefix = if dir.as_os_str().is_empty() {
684            "".to_string()
685        } else {
686            format!("{}/", dir.to_string_lossy())
687        };
688        if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
689            return (Some(found), false);
690        }
691        if let Some(found) = try_prefixes(&["", "src/", "lib/"]) {
692            return (Some(found), false);
693        }
694        return (None, false);
695    }
696
697    (None, true)
698}
699
700// ---------------------------------------------------------------------------
701// Bash
702// ---------------------------------------------------------------------------
703
704fn resolve_bash(
705    imp: &ImportInfo,
706    file_path: &str,
707    ctx: &ResolverContext,
708) -> (Option<String>, bool) {
709    let source = imp.source.trim();
710    if source.is_empty() {
711        return (None, true);
712    }
713    let source_rel = source.trim_start_matches("./").trim_start_matches('/');
714
715    let try_prefixes = |prefixes: &[&str]| -> Option<String> {
716        let mut candidates: Vec<String> = vec![source_rel.to_string()];
717        if !source_rel.ends_with(".sh") {
718            candidates.push(format!("{source_rel}.sh"));
719        }
720        for prefix in prefixes {
721            for c in candidates.iter() {
722                let p = format!("{prefix}{c}");
723                if ctx.file_exists(&p) {
724                    return Some(p);
725                }
726            }
727        }
728        None
729    };
730
731    if source.starts_with('.') || source.starts_with('/') || source_rel.contains('/') {
732        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
733        let dir_prefix = if dir.as_os_str().is_empty() {
734            "".to_string()
735        } else {
736            format!("{}/", dir.to_string_lossy())
737        };
738        if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
739            return (Some(found), false);
740        }
741        if let Some(found) = try_prefixes(&["", "scripts/", "bin/"]) {
742            return (Some(found), false);
743        }
744        return (None, false);
745    }
746
747    (None, true)
748}
749
750// ---------------------------------------------------------------------------
751// Dart
752// ---------------------------------------------------------------------------
753
754fn resolve_dart(
755    imp: &ImportInfo,
756    file_path: &str,
757    ctx: &ResolverContext,
758) -> (Option<String>, bool) {
759    let source = imp.source.trim();
760    if source.is_empty() {
761        return (None, true);
762    }
763    if source.starts_with("dart:") {
764        return (None, true);
765    }
766
767    let try_prefixes = |prefixes: &[&str], rel: &str| -> Option<String> {
768        let rel = rel.trim_start_matches("./").trim_start_matches('/').trim();
769        let mut candidates: Vec<String> = vec![rel.to_string()];
770        if !rel.ends_with(".dart") {
771            candidates.push(format!("{rel}.dart"));
772        }
773        for prefix in prefixes {
774            for c in candidates.iter() {
775                let p = format!("{prefix}{c}");
776                if ctx.file_exists(&p) {
777                    return Some(p);
778                }
779            }
780        }
781        None
782    };
783
784    if source.starts_with("package:") {
785        if let Some(pkg) = ctx.dart_package.as_deref() {
786            let prefix = format!("package:{pkg}/");
787            if let Some(rest) = source.strip_prefix(&prefix) {
788                if let Some(found) = try_prefixes(&["lib/", ""], rest) {
789                    return (Some(found), false);
790                }
791                return (None, false);
792            }
793        }
794        return (None, true);
795    }
796
797    if source.starts_with('.') || source.starts_with('/') {
798        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
799        let dir_prefix = if dir.as_os_str().is_empty() {
800            "".to_string()
801        } else {
802            format!("{}/", dir.to_string_lossy())
803        };
804        if let Some(found) = try_prefixes(&[dir_prefix.as_str()], source) {
805            return (Some(found), false);
806        }
807        if let Some(found) = try_prefixes(&["", "lib/"], source) {
808            return (Some(found), false);
809        }
810        return (None, false);
811    }
812
813    (None, true)
814}
815
816// ---------------------------------------------------------------------------
817// Zig
818// ---------------------------------------------------------------------------
819
820fn resolve_zig(imp: &ImportInfo, file_path: &str, ctx: &ResolverContext) -> (Option<String>, bool) {
821    let source = imp.source.trim();
822    if source.is_empty() {
823        return (None, true);
824    }
825    let source_rel = source.trim_start_matches("./").trim_start_matches('/');
826    if source_rel == "std" {
827        return (None, true);
828    }
829
830    let try_prefixes = |prefixes: &[&str]| -> Option<String> {
831        let mut candidates: Vec<String> = vec![source_rel.to_string()];
832        if !source_rel.ends_with(".zig") {
833            candidates.push(format!("{source_rel}.zig"));
834        }
835        for prefix in prefixes {
836            for c in candidates.iter() {
837                let p = format!("{prefix}{c}");
838                if ctx.file_exists(&p) {
839                    return Some(p);
840                }
841            }
842        }
843        None
844    };
845
846    if source.starts_with('.') || source_rel.contains('/') || source_rel.ends_with(".zig") {
847        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
848        let dir_prefix = if dir.as_os_str().is_empty() {
849            "".to_string()
850        } else {
851            format!("{}/", dir.to_string_lossy())
852        };
853        if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
854            return (Some(found), false);
855        }
856        if let Some(found) = try_prefixes(&["", "src/"]) {
857            return (Some(found), false);
858        }
859        return (None, false);
860    }
861
862    (None, true)
863}
864
865// ---------------------------------------------------------------------------
866// Config Loaders
867// ---------------------------------------------------------------------------
868
869fn load_tsconfig_paths(root: &Path) -> HashMap<String, String> {
870    let mut paths = HashMap::new();
871
872    let candidates = ["tsconfig.json", "tsconfig.base.json", "jsconfig.json"];
873    for name in &candidates {
874        let tsconfig_path = root.join(name);
875        if let Ok(content) = std::fs::read_to_string(&tsconfig_path) {
876            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
877                if let Some(compiler) = json.get("compilerOptions") {
878                    let base_url = compiler
879                        .get("baseUrl")
880                        .and_then(|v| v.as_str())
881                        .unwrap_or(".");
882
883                    if let Some(path_map) = compiler.get("paths").and_then(|v| v.as_object()) {
884                        for (pattern, targets) in path_map {
885                            if let Some(first_target) = targets
886                                .as_array()
887                                .and_then(|a| a.first())
888                                .and_then(|v| v.as_str())
889                            {
890                                let resolved = if base_url == "." {
891                                    first_target.to_string()
892                                } else {
893                                    format!("{base_url}/{first_target}")
894                                };
895                                paths.insert(pattern.clone(), resolved);
896                            }
897                        }
898                    }
899                }
900            }
901            break;
902        }
903    }
904
905    paths
906}
907
908fn load_go_module(root: &Path) -> Option<String> {
909    let go_mod = root.join("go.mod");
910    let content = std::fs::read_to_string(go_mod).ok()?;
911    for line in content.lines() {
912        let trimmed = line.trim();
913        if trimmed.starts_with("module ") {
914            return Some(trimmed.strip_prefix("module ")?.trim().to_string());
915        }
916    }
917    None
918}
919
920fn load_dart_package(root: &Path) -> Option<String> {
921    let pubspec = root.join("pubspec.yaml");
922    let content = std::fs::read_to_string(pubspec).ok()?;
923    for line in content.lines() {
924        let trimmed = line.trim();
925        if let Some(rest) = trimmed.strip_prefix("name:") {
926            let name = rest.trim();
927            if !name.is_empty() {
928                return Some(name.to_string());
929            }
930        }
931    }
932    None
933}
934
935// ---------------------------------------------------------------------------
936// Helpers
937// ---------------------------------------------------------------------------
938
939fn normalize_path(path: &Path) -> String {
940    let mut parts: Vec<&str> = Vec::new();
941    for component in path.components() {
942        match component {
943            std::path::Component::ParentDir => {
944                parts.pop();
945            }
946            std::path::Component::CurDir => {}
947            std::path::Component::Normal(s) => {
948                parts.push(s.to_str().unwrap_or(""));
949            }
950            _ => {}
951        }
952    }
953    parts.join("/")
954}
955
956// ---------------------------------------------------------------------------
957// Tests
958// ---------------------------------------------------------------------------
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963    use crate::core::deep_queries::{ImportInfo, ImportKind};
964
965    fn make_ctx(files: &[&str]) -> ResolverContext {
966        ResolverContext {
967            project_root: PathBuf::from("/project"),
968            file_paths: files.iter().map(|s| s.to_string()).collect(),
969            tsconfig_paths: HashMap::new(),
970            go_module: None,
971            dart_package: None,
972            file_set: files.iter().map(|s| s.to_string()).collect(),
973        }
974    }
975
976    fn make_import(source: &str) -> ImportInfo {
977        ImportInfo {
978            source: source.to_string(),
979            names: Vec::new(),
980            kind: ImportKind::Named,
981            line: 1,
982            is_type_only: false,
983        }
984    }
985
986    // --- TypeScript ---
987
988    #[test]
989    fn ts_relative_import() {
990        let ctx = make_ctx(&["src/components/Button.tsx", "src/utils/helpers.ts"]);
991        let imp = make_import("./helpers");
992        let results = resolve_imports(&[imp], "src/utils/index.ts", "ts", &ctx);
993        assert_eq!(
994            results[0].resolved_path.as_deref(),
995            Some("src/utils/helpers.ts")
996        );
997        assert!(!results[0].is_external);
998    }
999
1000    #[test]
1001    fn ts_relative_parent() {
1002        let ctx = make_ctx(&["src/utils.ts", "src/components/Button.tsx"]);
1003        let imp = make_import("../utils");
1004        let results = resolve_imports(&[imp], "src/components/Button.tsx", "ts", &ctx);
1005        assert_eq!(results[0].resolved_path.as_deref(), Some("src/utils.ts"));
1006    }
1007
1008    #[test]
1009    fn ts_index_file() {
1010        let ctx = make_ctx(&["src/components/index.ts", "src/app.ts"]);
1011        let imp = make_import("./components");
1012        let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
1013        assert_eq!(
1014            results[0].resolved_path.as_deref(),
1015            Some("src/components/index.ts")
1016        );
1017    }
1018
1019    #[test]
1020    fn ts_relative_js_specifier_resolves_to_ts_source() {
1021        let ctx = make_ctx(&["src/b.ts", "src/a.ts"]);
1022        let imp = make_import("./b.js");
1023        let results = resolve_imports(&[imp], "src/a.ts", "ts", &ctx);
1024        assert_eq!(results[0].resolved_path.as_deref(), Some("src/b.ts"));
1025        assert!(!results[0].is_external);
1026    }
1027
1028    #[test]
1029    fn ts_relative_jsx_specifier_resolves_to_tsx_source() {
1030        let ctx = make_ctx(&["src/Button.tsx", "src/App.tsx"]);
1031        let imp = make_import("./Button.jsx");
1032        let results = resolve_imports(&[imp], "src/App.tsx", "tsx", &ctx);
1033        assert_eq!(results[0].resolved_path.as_deref(), Some("src/Button.tsx"));
1034    }
1035
1036    #[test]
1037    fn ts_relative_mjs_specifier_resolves_to_mts_source() {
1038        let ctx = make_ctx(&["src/utils.mts", "src/main.mts"]);
1039        let imp = make_import("./utils.mjs");
1040        let results = resolve_imports(&[imp], "src/main.mts", "ts", &ctx);
1041        assert_eq!(results[0].resolved_path.as_deref(), Some("src/utils.mts"));
1042    }
1043
1044    #[test]
1045    fn ts_relative_js_specifier_falls_back_to_js_file() {
1046        let ctx = make_ctx(&["src/legacy.js", "src/app.ts"]);
1047        let imp = make_import("./legacy.js");
1048        let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
1049        assert_eq!(results[0].resolved_path.as_deref(), Some("src/legacy.js"));
1050    }
1051
1052    #[test]
1053    fn ts_external_package() {
1054        let ctx = make_ctx(&["src/app.ts"]);
1055        let imp = make_import("react");
1056        let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
1057        assert!(results[0].is_external);
1058        assert!(results[0].resolved_path.is_none());
1059    }
1060
1061    #[test]
1062    fn ts_tsconfig_paths() {
1063        let mut ctx = make_ctx(&["src/lib/utils/format.ts"]);
1064        ctx.tsconfig_paths
1065            .insert("@utils/*".to_string(), "src/lib/utils/*".to_string());
1066        let imp = make_import("@utils/format");
1067        let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
1068        assert_eq!(
1069            results[0].resolved_path.as_deref(),
1070            Some("src/lib/utils/format.ts")
1071        );
1072        assert!(!results[0].is_external);
1073    }
1074
1075    // --- Rust ---
1076
1077    #[test]
1078    fn rust_crate_import() {
1079        let ctx = make_ctx(&["src/core/session.rs", "src/main.rs"]);
1080        let imp = make_import("crate::core::session");
1081        let results = resolve_imports(&[imp], "src/server.rs", "rs", &ctx);
1082        assert_eq!(
1083            results[0].resolved_path.as_deref(),
1084            Some("src/core/session.rs")
1085        );
1086        assert!(!results[0].is_external);
1087    }
1088
1089    #[test]
1090    fn rust_mod_rs() {
1091        let ctx = make_ctx(&["src/core/mod.rs", "src/main.rs"]);
1092        let imp = make_import("crate::core");
1093        let results = resolve_imports(&[imp], "src/main.rs", "rs", &ctx);
1094        assert_eq!(results[0].resolved_path.as_deref(), Some("src/core/mod.rs"));
1095    }
1096
1097    #[test]
1098    fn rust_external_crate() {
1099        let ctx = make_ctx(&["src/main.rs"]);
1100        let imp = make_import("anyhow::Result");
1101        let results = resolve_imports(&[imp], "src/main.rs", "rs", &ctx);
1102        assert!(results[0].is_external);
1103    }
1104
1105    #[test]
1106    fn rust_symbol_in_module() {
1107        let ctx = make_ctx(&["src/core/session.rs"]);
1108        let imp = make_import("crate::core::session::SessionState");
1109        let results = resolve_imports(&[imp], "src/server.rs", "rs", &ctx);
1110        assert_eq!(
1111            results[0].resolved_path.as_deref(),
1112            Some("src/core/session.rs")
1113        );
1114    }
1115
1116    // --- Python ---
1117
1118    #[test]
1119    fn python_absolute_import() {
1120        let ctx = make_ctx(&["models/user.py", "app.py"]);
1121        let imp = make_import("models.user");
1122        let results = resolve_imports(&[imp], "app.py", "py", &ctx);
1123        assert_eq!(results[0].resolved_path.as_deref(), Some("models/user.py"));
1124    }
1125
1126    #[test]
1127    fn python_package_init() {
1128        let ctx = make_ctx(&["utils/__init__.py", "app.py"]);
1129        let imp = make_import("utils");
1130        let results = resolve_imports(&[imp], "app.py", "py", &ctx);
1131        assert_eq!(
1132            results[0].resolved_path.as_deref(),
1133            Some("utils/__init__.py")
1134        );
1135    }
1136
1137    #[test]
1138    fn python_relative_import() {
1139        let ctx = make_ctx(&["pkg/utils.py", "pkg/main.py"]);
1140        let imp = make_import(".utils");
1141        let results = resolve_imports(&[imp], "pkg/main.py", "py", &ctx);
1142        assert_eq!(results[0].resolved_path.as_deref(), Some("pkg/utils.py"));
1143    }
1144
1145    #[test]
1146    fn python_stdlib() {
1147        let ctx = make_ctx(&["app.py"]);
1148        let imp = make_import("os");
1149        let results = resolve_imports(&[imp], "app.py", "py", &ctx);
1150        assert!(results[0].is_external);
1151    }
1152
1153    // --- Go ---
1154
1155    #[test]
1156    fn go_internal_package() {
1157        let mut ctx = make_ctx(&["cmd/server/main.go", "internal/auth/auth.go"]);
1158        ctx.go_module = Some("github.com/org/project".to_string());
1159        let imp = make_import("github.com/org/project/internal/auth");
1160        let results = resolve_imports(&[imp], "cmd/server/main.go", "go", &ctx);
1161        assert_eq!(results[0].resolved_path.as_deref(), Some("internal/auth"));
1162        assert!(!results[0].is_external);
1163    }
1164
1165    #[test]
1166    fn go_external_package() {
1167        let ctx = make_ctx(&["main.go"]);
1168        let imp = make_import("fmt");
1169        let results = resolve_imports(&[imp], "main.go", "go", &ctx);
1170        assert!(results[0].is_external);
1171    }
1172
1173    // --- Java ---
1174
1175    #[test]
1176    fn java_internal_class() {
1177        let ctx = make_ctx(&[
1178            "src/main/java/com/example/service/UserService.java",
1179            "src/main/java/com/example/model/User.java",
1180        ]);
1181        let imp = make_import("com.example.model.User");
1182        let results = resolve_imports(
1183            &[imp],
1184            "src/main/java/com/example/service/UserService.java",
1185            "java",
1186            &ctx,
1187        );
1188        assert_eq!(
1189            results[0].resolved_path.as_deref(),
1190            Some("src/main/java/com/example/model/User.java")
1191        );
1192        assert!(!results[0].is_external);
1193    }
1194
1195    #[test]
1196    fn java_stdlib() {
1197        let ctx = make_ctx(&["Main.java"]);
1198        let imp = make_import("java.util.List");
1199        let results = resolve_imports(&[imp], "Main.java", "java", &ctx);
1200        assert!(results[0].is_external);
1201    }
1202
1203    // --- Edge cases ---
1204
1205    #[test]
1206    fn empty_imports() {
1207        let ctx = make_ctx(&["src/main.rs"]);
1208        let results = resolve_imports(&[], "src/main.rs", "rs", &ctx);
1209        assert!(results.is_empty());
1210    }
1211
1212    #[test]
1213    fn unsupported_language() {
1214        let ctx = make_ctx(&["main.rb"]);
1215        let imp = make_import("some_module");
1216        let results = resolve_imports(&[imp], "main.rb", "rb", &ctx);
1217        assert!(results[0].is_external);
1218    }
1219
1220    #[test]
1221    fn c_include_resolves_from_include_dir() {
1222        let ctx = make_ctx(&["include/foo/bar.h", "src/main.c"]);
1223        let imp = make_import("foo/bar.h");
1224        let results = resolve_imports(&[imp], "src/main.c", "c", &ctx);
1225        assert_eq!(
1226            results[0].resolved_path.as_deref(),
1227            Some("include/foo/bar.h")
1228        );
1229        assert!(!results[0].is_external);
1230    }
1231
1232    #[test]
1233    fn ruby_require_relative_resolves() {
1234        let ctx = make_ctx(&["lib/utils.rb", "app.rb"]);
1235        let imp = make_import("./lib/utils");
1236        let results = resolve_imports(&[imp], "app.rb", "rb", &ctx);
1237        assert_eq!(results[0].resolved_path.as_deref(), Some("lib/utils.rb"));
1238        assert!(!results[0].is_external);
1239    }
1240
1241    #[test]
1242    fn php_require_resolves() {
1243        let ctx = make_ctx(&["vendor/autoload.php", "index.php"]);
1244        let imp = make_import("./vendor/autoload.php");
1245        let results = resolve_imports(&[imp], "index.php", "php", &ctx);
1246        assert_eq!(
1247            results[0].resolved_path.as_deref(),
1248            Some("vendor/autoload.php")
1249        );
1250        assert!(!results[0].is_external);
1251    }
1252
1253    #[test]
1254    fn bash_source_resolves() {
1255        let ctx = make_ctx(&["scripts/env.sh", "main.sh"]);
1256        let imp = make_import("./scripts/env.sh");
1257        let results = resolve_imports(&[imp], "main.sh", "sh", &ctx);
1258        assert_eq!(results[0].resolved_path.as_deref(), Some("scripts/env.sh"));
1259        assert!(!results[0].is_external);
1260    }
1261
1262    #[test]
1263    fn dart_package_import_resolves_to_lib() {
1264        let mut ctx = make_ctx(&["lib/src/util.dart", "lib/app.dart"]);
1265        ctx.dart_package = Some("myapp".to_string());
1266        let imp = make_import("package:myapp/src/util.dart");
1267        let results = resolve_imports(&[imp], "lib/app.dart", "dart", &ctx);
1268        assert_eq!(
1269            results[0].resolved_path.as_deref(),
1270            Some("lib/src/util.dart")
1271        );
1272        assert!(!results[0].is_external);
1273    }
1274
1275    #[test]
1276    fn kotlin_import_resolves_to_src_main_kotlin() {
1277        let ctx = make_ctx(&[
1278            "src/main/kotlin/com/example/service/UserService.kt",
1279            "src/main/kotlin/com/example/App.kt",
1280        ]);
1281        let imp = make_import("com.example.service.UserService");
1282        let results = resolve_imports(&[imp], "src/main/kotlin/com/example/App.kt", "kt", &ctx);
1283        assert_eq!(
1284            results[0].resolved_path.as_deref(),
1285            Some("src/main/kotlin/com/example/service/UserService.kt")
1286        );
1287        assert!(!results[0].is_external);
1288    }
1289
1290    #[test]
1291    fn kotlin_stdlib_import_is_external() {
1292        let ctx = make_ctx(&["src/main/kotlin/App.kt"]);
1293        let imp = make_import("kotlin.collections.List");
1294        let results = resolve_imports(&[imp], "src/main/kotlin/App.kt", "kt", &ctx);
1295        assert!(results[0].is_external);
1296    }
1297
1298    #[test]
1299    fn kotlin_import_resolves_java_file() {
1300        let ctx = make_ctx(&[
1301            "src/main/java/com/example/LegacyUtil.java",
1302            "src/main/kotlin/com/example/App.kt",
1303        ]);
1304        let imp = make_import("com.example.LegacyUtil");
1305        let results = resolve_imports(&[imp], "src/main/kotlin/com/example/App.kt", "kt", &ctx);
1306        assert_eq!(
1307            results[0].resolved_path.as_deref(),
1308            Some("src/main/java/com/example/LegacyUtil.java")
1309        );
1310        assert!(!results[0].is_external);
1311    }
1312}