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