Skip to main content

lean_ctx/core/
import_resolver.rs

1//! Import-to-file resolution for the top 5 languages.
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
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14use super::deep_queries::ImportInfo;
15
16#[derive(Debug, Clone)]
17pub struct ResolvedImport {
18    pub source: String,
19    pub resolved_path: Option<String>,
20    pub is_external: bool,
21    pub line: usize,
22}
23
24#[derive(Debug)]
25pub struct ResolverContext {
26    pub project_root: PathBuf,
27    pub file_paths: Vec<String>,
28    pub tsconfig_paths: HashMap<String, String>,
29    pub go_module: Option<String>,
30    file_set: std::collections::HashSet<String>,
31}
32
33impl ResolverContext {
34    pub fn new(project_root: &Path, file_paths: Vec<String>) -> Self {
35        let file_set: std::collections::HashSet<String> = file_paths.iter().cloned().collect();
36
37        let tsconfig_paths = load_tsconfig_paths(project_root);
38        let go_module = load_go_module(project_root);
39
40        Self {
41            project_root: project_root.to_path_buf(),
42            file_paths,
43            tsconfig_paths,
44            go_module,
45            file_set,
46        }
47    }
48
49    fn file_exists(&self, rel_path: &str) -> bool {
50        self.file_set.contains(rel_path)
51    }
52}
53
54pub fn resolve_imports(
55    imports: &[ImportInfo],
56    file_path: &str,
57    ext: &str,
58    ctx: &ResolverContext,
59) -> Vec<ResolvedImport> {
60    imports
61        .iter()
62        .map(|imp| {
63            let (resolved, is_external) = resolve_one(imp, file_path, ext, ctx);
64            ResolvedImport {
65                source: imp.source.clone(),
66                resolved_path: resolved,
67                is_external,
68                line: imp.line,
69            }
70        })
71        .collect()
72}
73
74fn resolve_one(
75    imp: &ImportInfo,
76    file_path: &str,
77    ext: &str,
78    ctx: &ResolverContext,
79) -> (Option<String>, bool) {
80    match ext {
81        "ts" | "tsx" | "js" | "jsx" => resolve_ts(imp, file_path, ctx),
82        "rs" => resolve_rust(imp, file_path, ctx),
83        "py" => resolve_python(imp, file_path, ctx),
84        "go" => resolve_go(imp, ctx),
85        "java" => resolve_java(imp, ctx),
86        _ => (None, true),
87    }
88}
89
90// ---------------------------------------------------------------------------
91// TypeScript / JavaScript
92// ---------------------------------------------------------------------------
93
94fn resolve_ts(imp: &ImportInfo, file_path: &str, ctx: &ResolverContext) -> (Option<String>, bool) {
95    let source = &imp.source;
96
97    if source.starts_with('.') {
98        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
99        let resolved = dir.join(source);
100        let normalized = normalize_path(&resolved);
101
102        if let Some(found) = try_ts_extensions(&normalized, ctx) {
103            return (Some(found), false);
104        }
105        return (None, false);
106    }
107
108    if let Some(mapped) = resolve_tsconfig_path(source, ctx) {
109        return (Some(mapped), false);
110    }
111
112    (None, true)
113}
114
115fn try_ts_extensions(base: &str, ctx: &ResolverContext) -> Option<String> {
116    let extensions = [".ts", ".tsx", ".js", ".jsx", ".d.ts"];
117
118    if ctx.file_exists(base) {
119        return Some(base.to_string());
120    }
121
122    for ext in &extensions {
123        let with_ext = format!("{base}{ext}");
124        if ctx.file_exists(&with_ext) {
125            return Some(with_ext);
126        }
127    }
128
129    let index_extensions = ["index.ts", "index.tsx", "index.js", "index.jsx"];
130    for idx in &index_extensions {
131        let index_path = format!("{base}/{idx}");
132        if ctx.file_exists(&index_path) {
133            return Some(index_path);
134        }
135    }
136
137    None
138}
139
140fn resolve_tsconfig_path(source: &str, ctx: &ResolverContext) -> Option<String> {
141    for (pattern, target) in &ctx.tsconfig_paths {
142        let prefix = pattern.trim_end_matches('*');
143        if let Some(remainder) = source.strip_prefix(prefix) {
144            let target_base = target.trim_end_matches('*');
145            let candidate = format!("{target_base}{remainder}");
146            if let Some(found) = try_ts_extensions(&candidate, ctx) {
147                return Some(found);
148            }
149        }
150    }
151    None
152}
153
154// ---------------------------------------------------------------------------
155// Rust
156// ---------------------------------------------------------------------------
157
158fn resolve_rust(
159    imp: &ImportInfo,
160    file_path: &str,
161    ctx: &ResolverContext,
162) -> (Option<String>, bool) {
163    let source = &imp.source;
164
165    if source.starts_with("crate::")
166        || source.starts_with("super::")
167        || source.starts_with("self::")
168    {
169        let cleaned = source.replace("crate::", "").replace("self::", "");
170
171        let resolved = if source.starts_with("super::") {
172            let dir = Path::new(file_path).parent().and_then(|p| p.parent());
173            match dir {
174                Some(d) => {
175                    let rest = source.trim_start_matches("super::");
176                    d.join(rest.replace("::", "/"))
177                        .to_string_lossy()
178                        .to_string()
179                }
180                None => cleaned.replace("::", "/"),
181            }
182        } else {
183            cleaned.replace("::", "/")
184        };
185
186        if let Some(found) = try_rust_paths(&resolved, ctx) {
187            return (Some(found), false);
188        }
189        return (None, false);
190    }
191
192    let parts: Vec<&str> = source.split("::").collect();
193    if parts.is_empty() {
194        return (None, true);
195    }
196
197    let is_external = !source.starts_with("crate")
198        && !ctx.file_paths.iter().any(|f| {
199            let stem = Path::new(f)
200                .file_stem()
201                .and_then(|s| s.to_str())
202                .unwrap_or("");
203            stem == parts[0]
204        });
205
206    if is_external {
207        return (None, true);
208    }
209
210    let as_path = source.replace("::", "/");
211    if let Some(found) = try_rust_paths(&as_path, ctx) {
212        return (Some(found), false);
213    }
214
215    (None, is_external)
216}
217
218fn try_rust_paths(base: &str, ctx: &ResolverContext) -> Option<String> {
219    let prefixes = ["", "src/", "rust/src/"];
220    for prefix in &prefixes {
221        let candidate = format!("{prefix}{base}.rs");
222        if ctx.file_exists(&candidate) {
223            return Some(candidate);
224        }
225        let mod_candidate = format!("{prefix}{base}/mod.rs");
226        if ctx.file_exists(&mod_candidate) {
227            return Some(mod_candidate);
228        }
229    }
230
231    let parts: Vec<&str> = base.rsplitn(2, '/').collect();
232    if parts.len() == 2 {
233        let parent = parts[1];
234        for prefix in &prefixes {
235            let candidate = format!("{prefix}{parent}.rs");
236            if ctx.file_exists(&candidate) {
237                return Some(candidate);
238            }
239        }
240    }
241
242    None
243}
244
245// ---------------------------------------------------------------------------
246// Python
247// ---------------------------------------------------------------------------
248
249fn resolve_python(
250    imp: &ImportInfo,
251    file_path: &str,
252    ctx: &ResolverContext,
253) -> (Option<String>, bool) {
254    let source = &imp.source;
255
256    if source.starts_with('.') {
257        let dot_count = source.chars().take_while(|c| *c == '.').count();
258        let module_part = &source[dot_count..];
259
260        let mut dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
261        for _ in 1..dot_count {
262            dir = dir.parent().unwrap_or(Path::new(""));
263        }
264
265        let as_path = module_part.replace('.', "/");
266        let base = if as_path.is_empty() {
267            dir.to_string_lossy().to_string()
268        } else {
269            format!("{}/{as_path}", dir.display())
270        };
271
272        if let Some(found) = try_python_paths(&base, ctx) {
273            return (Some(found), false);
274        }
275        return (None, false);
276    }
277
278    let as_path = source.replace('.', "/");
279
280    if let Some(found) = try_python_paths(&as_path, ctx) {
281        return (Some(found), false);
282    }
283
284    let is_stdlib = is_python_stdlib(source);
285    (
286        None,
287        is_stdlib || !ctx.file_paths.iter().any(|f| f.contains(&as_path)),
288    )
289}
290
291fn try_python_paths(base: &str, ctx: &ResolverContext) -> Option<String> {
292    let py_file = format!("{base}.py");
293    if ctx.file_exists(&py_file) {
294        return Some(py_file);
295    }
296
297    let init_file = format!("{base}/__init__.py");
298    if ctx.file_exists(&init_file) {
299        return Some(init_file);
300    }
301
302    let prefixes = ["src/", "lib/"];
303    for prefix in &prefixes {
304        let candidate = format!("{prefix}{base}.py");
305        if ctx.file_exists(&candidate) {
306            return Some(candidate);
307        }
308        let init = format!("{prefix}{base}/__init__.py");
309        if ctx.file_exists(&init) {
310            return Some(init);
311        }
312    }
313
314    None
315}
316
317fn is_python_stdlib(module: &str) -> bool {
318    let first = module.split('.').next().unwrap_or(module);
319    matches!(
320        first,
321        "os" | "sys"
322            | "json"
323            | "re"
324            | "math"
325            | "datetime"
326            | "typing"
327            | "collections"
328            | "itertools"
329            | "functools"
330            | "pathlib"
331            | "io"
332            | "abc"
333            | "enum"
334            | "dataclasses"
335            | "logging"
336            | "unittest"
337            | "argparse"
338            | "subprocess"
339            | "threading"
340            | "multiprocessing"
341            | "socket"
342            | "http"
343            | "urllib"
344            | "hashlib"
345            | "hmac"
346            | "secrets"
347            | "time"
348            | "copy"
349            | "pprint"
350            | "textwrap"
351            | "shutil"
352            | "tempfile"
353            | "glob"
354            | "fnmatch"
355            | "contextlib"
356            | "inspect"
357            | "importlib"
358            | "pickle"
359            | "shelve"
360            | "csv"
361            | "configparser"
362            | "struct"
363            | "codecs"
364            | "string"
365            | "difflib"
366            | "ast"
367            | "dis"
368            | "traceback"
369            | "warnings"
370            | "concurrent"
371            | "asyncio"
372            | "signal"
373            | "select"
374    )
375}
376
377// ---------------------------------------------------------------------------
378// Go
379// ---------------------------------------------------------------------------
380
381fn resolve_go(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
382    let source = &imp.source;
383
384    if let Some(ref go_mod) = ctx.go_module {
385        if source.starts_with(go_mod.as_str()) {
386            let relative = source.strip_prefix(go_mod.as_str()).unwrap_or(source);
387            let relative = relative.trim_start_matches('/');
388
389            if let Some(found) = try_go_package(relative, ctx) {
390                return (Some(found), false);
391            }
392            return (None, false);
393        }
394    }
395
396    if let Some(found) = try_go_package(source, ctx) {
397        return (Some(found), false);
398    }
399
400    (None, true)
401}
402
403fn try_go_package(pkg_path: &str, ctx: &ResolverContext) -> Option<String> {
404    for file in &ctx.file_paths {
405        if file.ends_with(".go") {
406            let dir = Path::new(file).parent()?.to_string_lossy();
407            if dir == pkg_path || dir.ends_with(pkg_path) {
408                return Some(dir.to_string());
409            }
410        }
411    }
412    None
413}
414
415// ---------------------------------------------------------------------------
416// Java
417// ---------------------------------------------------------------------------
418
419fn resolve_java(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
420    let source = &imp.source;
421
422    if source.starts_with("java.") || source.starts_with("javax.") || source.starts_with("sun.") {
423        return (None, true);
424    }
425
426    let parts: Vec<&str> = source.rsplitn(2, '.').collect();
427    if parts.len() < 2 {
428        return (None, true);
429    }
430
431    let class_name = parts[0];
432    let package_path = parts[1].replace('.', "/");
433    let file_path = format!("{package_path}/{class_name}.java");
434
435    let search_roots = ["", "src/main/java/", "src/", "app/src/main/java/"];
436    for root in &search_roots {
437        let candidate = format!("{root}{file_path}");
438        if ctx.file_exists(&candidate) {
439            return (Some(candidate), false);
440        }
441    }
442
443    (
444        None,
445        !ctx.file_paths.iter().any(|f| f.contains(&package_path)),
446    )
447}
448
449// ---------------------------------------------------------------------------
450// Config Loaders
451// ---------------------------------------------------------------------------
452
453fn load_tsconfig_paths(root: &Path) -> HashMap<String, String> {
454    let mut paths = HashMap::new();
455
456    let candidates = ["tsconfig.json", "tsconfig.base.json", "jsconfig.json"];
457    for name in &candidates {
458        let tsconfig_path = root.join(name);
459        if let Ok(content) = std::fs::read_to_string(&tsconfig_path) {
460            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
461                if let Some(compiler) = json.get("compilerOptions") {
462                    let base_url = compiler
463                        .get("baseUrl")
464                        .and_then(|v| v.as_str())
465                        .unwrap_or(".");
466
467                    if let Some(path_map) = compiler.get("paths").and_then(|v| v.as_object()) {
468                        for (pattern, targets) in path_map {
469                            if let Some(first_target) = targets
470                                .as_array()
471                                .and_then(|a| a.first())
472                                .and_then(|v| v.as_str())
473                            {
474                                let resolved = if base_url == "." {
475                                    first_target.to_string()
476                                } else {
477                                    format!("{base_url}/{first_target}")
478                                };
479                                paths.insert(pattern.clone(), resolved);
480                            }
481                        }
482                    }
483                }
484            }
485            break;
486        }
487    }
488
489    paths
490}
491
492fn load_go_module(root: &Path) -> Option<String> {
493    let go_mod = root.join("go.mod");
494    let content = std::fs::read_to_string(go_mod).ok()?;
495    for line in content.lines() {
496        let trimmed = line.trim();
497        if trimmed.starts_with("module ") {
498            return Some(trimmed.strip_prefix("module ")?.trim().to_string());
499        }
500    }
501    None
502}
503
504// ---------------------------------------------------------------------------
505// Helpers
506// ---------------------------------------------------------------------------
507
508fn normalize_path(path: &Path) -> String {
509    let mut parts: Vec<&str> = Vec::new();
510    for component in path.components() {
511        match component {
512            std::path::Component::ParentDir => {
513                parts.pop();
514            }
515            std::path::Component::CurDir => {}
516            std::path::Component::Normal(s) => {
517                parts.push(s.to_str().unwrap_or(""));
518            }
519            _ => {}
520        }
521    }
522    parts.join("/")
523}
524
525// ---------------------------------------------------------------------------
526// Tests
527// ---------------------------------------------------------------------------
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::core::deep_queries::{ImportInfo, ImportKind};
533
534    fn make_ctx(files: &[&str]) -> ResolverContext {
535        ResolverContext {
536            project_root: PathBuf::from("/project"),
537            file_paths: files.iter().map(|s| s.to_string()).collect(),
538            tsconfig_paths: HashMap::new(),
539            go_module: None,
540            file_set: files.iter().map(|s| s.to_string()).collect(),
541        }
542    }
543
544    fn make_import(source: &str) -> ImportInfo {
545        ImportInfo {
546            source: source.to_string(),
547            names: Vec::new(),
548            kind: ImportKind::Named,
549            line: 1,
550            is_type_only: false,
551        }
552    }
553
554    // --- TypeScript ---
555
556    #[test]
557    fn ts_relative_import() {
558        let ctx = make_ctx(&["src/components/Button.tsx", "src/utils/helpers.ts"]);
559        let imp = make_import("./helpers");
560        let results = resolve_imports(&[imp], "src/utils/index.ts", "ts", &ctx);
561        assert_eq!(
562            results[0].resolved_path.as_deref(),
563            Some("src/utils/helpers.ts")
564        );
565        assert!(!results[0].is_external);
566    }
567
568    #[test]
569    fn ts_relative_parent() {
570        let ctx = make_ctx(&["src/utils.ts", "src/components/Button.tsx"]);
571        let imp = make_import("../utils");
572        let results = resolve_imports(&[imp], "src/components/Button.tsx", "ts", &ctx);
573        assert_eq!(results[0].resolved_path.as_deref(), Some("src/utils.ts"));
574    }
575
576    #[test]
577    fn ts_index_file() {
578        let ctx = make_ctx(&["src/components/index.ts", "src/app.ts"]);
579        let imp = make_import("./components");
580        let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
581        assert_eq!(
582            results[0].resolved_path.as_deref(),
583            Some("src/components/index.ts")
584        );
585    }
586
587    #[test]
588    fn ts_external_package() {
589        let ctx = make_ctx(&["src/app.ts"]);
590        let imp = make_import("react");
591        let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
592        assert!(results[0].is_external);
593        assert!(results[0].resolved_path.is_none());
594    }
595
596    #[test]
597    fn ts_tsconfig_paths() {
598        let mut ctx = make_ctx(&["src/lib/utils/format.ts"]);
599        ctx.tsconfig_paths
600            .insert("@utils/*".to_string(), "src/lib/utils/*".to_string());
601        let imp = make_import("@utils/format");
602        let results = resolve_imports(&[imp], "src/app.ts", "ts", &ctx);
603        assert_eq!(
604            results[0].resolved_path.as_deref(),
605            Some("src/lib/utils/format.ts")
606        );
607        assert!(!results[0].is_external);
608    }
609
610    // --- Rust ---
611
612    #[test]
613    fn rust_crate_import() {
614        let ctx = make_ctx(&["src/core/session.rs", "src/main.rs"]);
615        let imp = make_import("crate::core::session");
616        let results = resolve_imports(&[imp], "src/server.rs", "rs", &ctx);
617        assert_eq!(
618            results[0].resolved_path.as_deref(),
619            Some("src/core/session.rs")
620        );
621        assert!(!results[0].is_external);
622    }
623
624    #[test]
625    fn rust_mod_rs() {
626        let ctx = make_ctx(&["src/core/mod.rs", "src/main.rs"]);
627        let imp = make_import("crate::core");
628        let results = resolve_imports(&[imp], "src/main.rs", "rs", &ctx);
629        assert_eq!(results[0].resolved_path.as_deref(), Some("src/core/mod.rs"));
630    }
631
632    #[test]
633    fn rust_external_crate() {
634        let ctx = make_ctx(&["src/main.rs"]);
635        let imp = make_import("anyhow::Result");
636        let results = resolve_imports(&[imp], "src/main.rs", "rs", &ctx);
637        assert!(results[0].is_external);
638    }
639
640    #[test]
641    fn rust_symbol_in_module() {
642        let ctx = make_ctx(&["src/core/session.rs"]);
643        let imp = make_import("crate::core::session::SessionState");
644        let results = resolve_imports(&[imp], "src/server.rs", "rs", &ctx);
645        assert_eq!(
646            results[0].resolved_path.as_deref(),
647            Some("src/core/session.rs")
648        );
649    }
650
651    // --- Python ---
652
653    #[test]
654    fn python_absolute_import() {
655        let ctx = make_ctx(&["models/user.py", "app.py"]);
656        let imp = make_import("models.user");
657        let results = resolve_imports(&[imp], "app.py", "py", &ctx);
658        assert_eq!(results[0].resolved_path.as_deref(), Some("models/user.py"));
659    }
660
661    #[test]
662    fn python_package_init() {
663        let ctx = make_ctx(&["utils/__init__.py", "app.py"]);
664        let imp = make_import("utils");
665        let results = resolve_imports(&[imp], "app.py", "py", &ctx);
666        assert_eq!(
667            results[0].resolved_path.as_deref(),
668            Some("utils/__init__.py")
669        );
670    }
671
672    #[test]
673    fn python_relative_import() {
674        let ctx = make_ctx(&["pkg/utils.py", "pkg/main.py"]);
675        let imp = make_import(".utils");
676        let results = resolve_imports(&[imp], "pkg/main.py", "py", &ctx);
677        assert_eq!(results[0].resolved_path.as_deref(), Some("pkg/utils.py"));
678    }
679
680    #[test]
681    fn python_stdlib() {
682        let ctx = make_ctx(&["app.py"]);
683        let imp = make_import("os");
684        let results = resolve_imports(&[imp], "app.py", "py", &ctx);
685        assert!(results[0].is_external);
686    }
687
688    // --- Go ---
689
690    #[test]
691    fn go_internal_package() {
692        let mut ctx = make_ctx(&["cmd/server/main.go", "internal/auth/auth.go"]);
693        ctx.go_module = Some("github.com/org/project".to_string());
694        let imp = make_import("github.com/org/project/internal/auth");
695        let results = resolve_imports(&[imp], "cmd/server/main.go", "go", &ctx);
696        assert_eq!(results[0].resolved_path.as_deref(), Some("internal/auth"));
697        assert!(!results[0].is_external);
698    }
699
700    #[test]
701    fn go_external_package() {
702        let ctx = make_ctx(&["main.go"]);
703        let imp = make_import("fmt");
704        let results = resolve_imports(&[imp], "main.go", "go", &ctx);
705        assert!(results[0].is_external);
706    }
707
708    // --- Java ---
709
710    #[test]
711    fn java_internal_class() {
712        let ctx = make_ctx(&[
713            "src/main/java/com/example/service/UserService.java",
714            "src/main/java/com/example/model/User.java",
715        ]);
716        let imp = make_import("com.example.model.User");
717        let results = resolve_imports(
718            &[imp],
719            "src/main/java/com/example/service/UserService.java",
720            "java",
721            &ctx,
722        );
723        assert_eq!(
724            results[0].resolved_path.as_deref(),
725            Some("src/main/java/com/example/model/User.java")
726        );
727        assert!(!results[0].is_external);
728    }
729
730    #[test]
731    fn java_stdlib() {
732        let ctx = make_ctx(&["Main.java"]);
733        let imp = make_import("java.util.List");
734        let results = resolve_imports(&[imp], "Main.java", "java", &ctx);
735        assert!(results[0].is_external);
736    }
737
738    // --- Edge cases ---
739
740    #[test]
741    fn empty_imports() {
742        let ctx = make_ctx(&["src/main.rs"]);
743        let results = resolve_imports(&[], "src/main.rs", "rs", &ctx);
744        assert!(results.is_empty());
745    }
746
747    #[test]
748    fn unsupported_language() {
749        let ctx = make_ctx(&["main.rb"]);
750        let imp = make_import("some_module");
751        let results = resolve_imports(&[imp], "main.rb", "rb", &ctx);
752        assert!(results[0].is_external);
753    }
754}