Skip to main content

codelens_engine/import_graph/
resolvers.rs

1use crate::project::ProjectRoot;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::{LazyLock, Mutex};
5
6// ── resolve_module dispatcher ────────────────────────────────────────────────
7
8pub(super) fn resolve_module(
9    project: &ProjectRoot,
10    source_file: &Path,
11    module: &str,
12) -> Option<String> {
13    let source_ext = source_file
14        .extension()
15        .and_then(|ext| ext.to_str())
16        .map(|e| e.to_ascii_lowercase())?;
17    match source_ext.as_str() {
18        "py" => resolve_python_module(project, source_file, module),
19        "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" => {
20            resolve_js_module(project, source_file, module)
21        }
22        "go" => resolve_go_module(project, module),
23        "java" | "kt" => resolve_jvm_module(project, module),
24        "rs" => resolve_rust_module(project, source_file, module),
25        "rb" => resolve_ruby_module(project, source_file, module),
26        "c" | "cc" | "cpp" | "cxx" | "h" | "hh" | "hpp" | "hxx" => {
27            resolve_c_module(project, source_file, module)
28        }
29        "php" => resolve_php_module(project, source_file, module),
30        "cs" => resolve_csharp_module(project, module),
31        "dart" => resolve_dart_module(project, source_file, module),
32        _ => None,
33    }
34}
35
36/// Resolve a raw import string to a relative path within the project. Public for use by the indexer.
37pub fn resolve_module_for_file(
38    project: &ProjectRoot,
39    source_file: &Path,
40    module: &str,
41) -> Option<String> {
42    resolve_module(project, source_file, module)
43}
44
45// ── Language-specific resolvers ──────────────────────────────────────────────
46
47/// Common Python source roots beyond the project root itself.
48const PYTHON_SOURCE_ROOTS: &[&str] = &["src", "lib", "app"];
49
50fn resolve_python_module(
51    project: &ProjectRoot,
52    source_file: &Path,
53    module: &str,
54) -> Option<String> {
55    let source_dir = source_file.parent()?;
56
57    // Handle relative imports: from . import foo, from ..models import User
58    if module.starts_with('.') {
59        let dots = module.chars().take_while(|&c| c == '.').count();
60        let remainder = &module[dots..];
61        let mut base = source_dir.to_path_buf();
62        // Each dot beyond the first goes up one directory
63        for _ in 1..dots {
64            base = base.parent()?.to_path_buf();
65        }
66        if remainder.is_empty() {
67            // `from . import foo` — resolve to __init__.py of current package
68            let init = base.join("__init__.py");
69            if init.is_file() {
70                return Some(project.to_relative(init));
71            }
72            return None;
73        }
74        let rel_path = remainder.replace('.', "/");
75        let candidates = [
76            base.join(format!("{rel_path}.py")),
77            base.join(&rel_path).join("__init__.py"),
78        ];
79        for candidate in candidates {
80            if candidate.is_file() {
81                return Some(project.to_relative(candidate));
82            }
83        }
84        return None;
85    }
86
87    let module_path = module.replace('.', "/");
88
89    // 1. Relative to source file's directory
90    let local_candidates = [
91        source_dir.join(format!("{module_path}.py")),
92        source_dir.join(&module_path).join("__init__.py"),
93    ];
94    for candidate in local_candidates {
95        if candidate.is_file() {
96            return Some(project.to_relative(candidate));
97        }
98    }
99
100    // 2. Relative to project root
101    let root = project.as_path();
102    let root_candidates = [
103        root.join(format!("{module_path}.py")),
104        root.join(&module_path).join("__init__.py"),
105    ];
106    for candidate in root_candidates {
107        if candidate.is_file() {
108            return Some(project.to_relative(candidate));
109        }
110    }
111
112    // 3. Relative to common Python source roots (src/, lib/, app/)
113    for src_root in PYTHON_SOURCE_ROOTS {
114        let base = root.join(src_root);
115        if !base.is_dir() {
116            continue;
117        }
118        let candidates = [
119            base.join(format!("{module_path}.py")),
120            base.join(&module_path).join("__init__.py"),
121        ];
122        for candidate in candidates {
123            if candidate.is_file() {
124                return Some(project.to_relative(candidate));
125            }
126        }
127    }
128
129    None
130}
131
132/// Parse tsconfig.json/jsconfig.json paths aliases.
133/// Returns Vec<(prefix_without_wildcard, target_dirs)>.
134fn parse_tsconfig_paths(root: &Path) -> Vec<(String, Vec<PathBuf>)> {
135    for config_name in ["tsconfig.json", "jsconfig.json"] {
136        let config_path = root.join(config_name);
137        let Ok(content) = std::fs::read_to_string(&config_path) else {
138            continue;
139        };
140        let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) else {
141            continue;
142        };
143        let Some(paths) = parsed
144            .get("compilerOptions")
145            .and_then(|co| co.get("paths"))
146            .and_then(|p| p.as_object())
147        else {
148            continue;
149        };
150        // baseUrl defaults to "." if not set
151        let base_url = parsed
152            .get("compilerOptions")
153            .and_then(|co| co.get("baseUrl"))
154            .and_then(|b| b.as_str())
155            .unwrap_or(".");
156        let base_dir = root.join(base_url);
157
158        let mut result = Vec::new();
159        for (pattern, targets) in paths {
160            // "@/*" → prefix "@/", targets ["./src/*"] → base_dir/src/
161            let prefix = pattern.trim_end_matches('*');
162            let target_dirs: Vec<PathBuf> = targets
163                .as_array()
164                .into_iter()
165                .flatten()
166                .filter_map(|t| t.as_str())
167                .map(|t| base_dir.join(t.trim_start_matches("./").trim_end_matches('*')))
168                .collect();
169            if !target_dirs.is_empty() {
170                result.push((prefix.to_string(), target_dirs));
171            }
172        }
173        return result;
174    }
175    Vec::new()
176}
177
178/// Cached tsconfig paths per project root (parsed once).
179#[allow(clippy::type_complexity)]
180static TSCONFIG_CACHE: LazyLock<Mutex<HashMap<PathBuf, Vec<(String, Vec<PathBuf>)>>>> =
181    LazyLock::new(|| Mutex::new(HashMap::new()));
182
183fn get_tsconfig_paths(root: &Path) -> Vec<(String, Vec<PathBuf>)> {
184    let mut cache = TSCONFIG_CACHE.lock().unwrap_or_else(|p| p.into_inner());
185    cache
186        .entry(root.to_path_buf())
187        .or_insert_with(|| parse_tsconfig_paths(root))
188        .clone()
189}
190
191fn resolve_js_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
192    let root = project.as_path();
193
194    // 1. Handle tsconfig.json paths aliases (covers @/, @components/, etc.)
195    let paths = get_tsconfig_paths(root);
196    for (prefix, target_dirs) in &paths {
197        if let Some(stripped) = module.strip_prefix(prefix.as_str()) {
198            for target_dir in target_dirs {
199                let base = target_dir.join(stripped);
200                for candidate in js_resolution_candidates(&base) {
201                    if candidate.is_file() {
202                        return Some(project.to_relative(candidate));
203                    }
204                }
205            }
206            return None;
207        }
208    }
209
210    // 2. Fallback: @/ and ~/ if no tsconfig found
211    if paths.is_empty() {
212        if let Some(stripped) = module.strip_prefix("@/") {
213            for src_root in &["src", "app", "lib"] {
214                let base = root.join(src_root).join(stripped);
215                for candidate in js_resolution_candidates(&base) {
216                    if candidate.is_file() {
217                        return Some(project.to_relative(candidate));
218                    }
219                }
220            }
221            return None;
222        }
223        if let Some(stripped) = module.strip_prefix("~/") {
224            let base = root.join("src").join(stripped);
225            for candidate in js_resolution_candidates(&base) {
226                if candidate.is_file() {
227                    return Some(project.to_relative(candidate));
228                }
229            }
230            return None;
231        }
232    }
233
234    // 3. Skip bare module specifiers (npm packages)
235    if !module.starts_with('.') && !module.starts_with('/') {
236        return None;
237    }
238
239    // 4. Relative or absolute paths
240    let base = if module.starts_with('/') {
241        root.join(module.trim_start_matches('/'))
242    } else {
243        source_file.parent()?.join(module)
244    };
245    for candidate in js_resolution_candidates(&base) {
246        if candidate.is_file() {
247            return Some(project.to_relative(candidate));
248        }
249    }
250    None
251}
252
253pub(super) fn js_resolution_candidates(base: &Path) -> Vec<PathBuf> {
254    let mut candidates = vec![base.to_path_buf()];
255    let extensions = ["js", "jsx", "ts", "tsx", "mjs", "cjs"];
256    if base.extension().is_none() {
257        for ext in extensions {
258            candidates.push(base.with_extension(ext));
259        }
260        for ext in extensions {
261            candidates.push(base.join(format!("index.{ext}")));
262        }
263    }
264    candidates
265}
266
267/// Go: resolve import path by stripping go.mod module prefix, then searching project dirs.
268fn resolve_go_module(project: &ProjectRoot, module: &str) -> Option<String> {
269    // Skip stdlib (no dots in first segment = stdlib)
270    if !module.contains('.') {
271        return None;
272    }
273
274    let root = project.as_path();
275
276    // Try to read go.mod module path and strip it
277    let module_prefix = std::fs::read_to_string(root.join("go.mod"))
278        .ok()
279        .and_then(|content| {
280            content
281                .lines()
282                .find(|l| l.starts_with("module "))
283                .map(|l| l.trim_start_matches("module ").trim().to_string())
284        });
285
286    // If import starts with module prefix, strip it to get relative path
287    let relative = if let Some(ref prefix) = module_prefix {
288        module
289            .strip_prefix(prefix)
290            .map(|s| s.trim_start_matches('/'))
291    } else {
292        None
293    };
294
295    // Search candidates: stripped path first, then last segment fallback
296    let candidates: Vec<&str> = if let Some(rel) = relative {
297        vec![rel]
298    } else {
299        // Fallback: try full path and last segment
300        let last = module.split('/').next_back().unwrap_or(module);
301        vec![module, last]
302    };
303
304    for candidate in candidates {
305        let dir = root.join(candidate);
306        if dir.is_dir() {
307            // Return first .go file in the directory
308            if let Ok(rd) = std::fs::read_dir(&dir) {
309                for entry in rd.flatten() {
310                    if entry.path().extension().and_then(|e| e.to_str()) == Some("go") {
311                        return Some(project.to_relative(entry.path()));
312                    }
313                }
314            }
315        }
316        let file = root.join(format!("{candidate}.go"));
317        if file.is_file() {
318            return Some(project.to_relative(file));
319        }
320    }
321    None
322}
323
324/// Java/Kotlin: convert fully-qualified class name to file path.
325fn resolve_jvm_module(project: &ProjectRoot, module: &str) -> Option<String> {
326    let path_part = module.replace('.', "/");
327    for ext in ["java", "kt"] {
328        let candidate = project.as_path().join(format!("{path_part}.{ext}"));
329        if candidate.is_file() {
330            return Some(project.to_relative(candidate));
331        }
332        for prefix in ["src/main/java", "src/main/kotlin", "src"] {
333            let candidate = project
334                .as_path()
335                .join(prefix)
336                .join(format!("{path_part}.{ext}"));
337            if candidate.is_file() {
338                return Some(project.to_relative(candidate));
339            }
340        }
341    }
342    None
343}
344
345/// Find the `src/` directory of a workspace crate given the crate name (using underscores).
346pub(super) fn find_workspace_crate_dir(project: &ProjectRoot, crate_name: &str) -> Option<PathBuf> {
347    let crates_dir = project.as_path().join("crates");
348    if !crates_dir.is_dir() {
349        return None;
350    }
351    for entry in std::fs::read_dir(&crates_dir).ok()?.flatten() {
352        let cargo_toml = entry.path().join("Cargo.toml");
353        if cargo_toml.is_file() {
354            let dir_name = entry.file_name().to_string_lossy().replace('-', "_");
355            if dir_name == crate_name {
356                return Some(entry.path().join("src"));
357            }
358        }
359    }
360    None
361}
362
363/// Rust: `use crate::foo::bar` -> look for src/foo/bar.rs or src/foo/bar/mod.rs.
364///       `mod foo;` -> look for foo.rs or foo/mod.rs relative to source dir.
365///       `use codelens_engine::ProjectRoot` -> strip workspace crate prefix and look in that crate's src/.
366fn resolve_rust_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
367    let stripped = module
368        .trim_start_matches("crate::")
369        .trim_start_matches("super::")
370        .trim_start_matches("self::");
371
372    // Check if the first segment matches a known workspace crate name.
373    let segments: Vec<&str> = stripped.splitn(2, "::").collect();
374    if segments.len() == 2 {
375        let first_seg = segments[0];
376        if let Some(crate_src) = find_workspace_crate_dir(project, first_seg) {
377            let remaining = segments[1].replace("::", "/");
378            let mut parts: Vec<&str> = remaining.split('/').collect();
379            while !parts.is_empty() {
380                let candidate_path = parts.join("/");
381                for candidate in [
382                    crate_src.join(format!("{candidate_path}.rs")),
383                    crate_src.join(&candidate_path).join("mod.rs"),
384                ] {
385                    if candidate.is_file() {
386                        return Some(project.to_relative(candidate));
387                    }
388                }
389                parts.pop();
390            }
391        }
392    }
393
394    let path_part = stripped.replace("::", "/");
395
396    let mut parts: Vec<&str> = path_part.split('/').collect();
397    while !parts.is_empty() {
398        let candidate_path = parts.join("/");
399        if let Some(parent) = source_file.parent() {
400            for candidate in [
401                parent.join(format!("{candidate_path}.rs")),
402                parent.join(&candidate_path).join("mod.rs"),
403            ] {
404                if candidate.is_file() {
405                    return Some(project.to_relative(candidate));
406                }
407            }
408        }
409        let src = project.as_path().join("src");
410        for candidate in [
411            src.join(format!("{candidate_path}.rs")),
412            src.join(&candidate_path).join("mod.rs"),
413        ] {
414            if candidate.is_file() {
415                return Some(project.to_relative(candidate));
416            }
417        }
418        if let Ok(entries) = std::fs::read_dir(project.as_path().join("crates")) {
419            for entry in entries.flatten() {
420                let crate_src = entry.path().join("src");
421                for candidate in [
422                    crate_src.join(format!("{candidate_path}.rs")),
423                    crate_src.join(&candidate_path).join("mod.rs"),
424                ] {
425                    if candidate.is_file() {
426                        return Some(project.to_relative(candidate));
427                    }
428                }
429            }
430        }
431        parts.pop();
432    }
433    None
434}
435
436/// Ruby: resolve require/require_relative paths to .rb files.
437/// Searches source dir, project root, lib/, and app/ (Rails convention).
438fn resolve_ruby_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
439    let source_dir = source_file.parent().unwrap_or(project.as_path());
440    let root = project.as_path();
441
442    let search_dirs: Vec<PathBuf> = if module.starts_with('.') {
443        vec![source_dir.to_path_buf()]
444    } else {
445        vec![root.to_path_buf(), root.join("lib"), root.join("app")]
446    };
447
448    for base_dir in &search_dirs {
449        if !base_dir.is_dir() {
450            continue;
451        }
452        let base = base_dir.join(module);
453        let with_ext = if base.extension().is_some() {
454            base.clone()
455        } else {
456            base.with_extension("rb")
457        };
458        if with_ext.is_file() {
459            return Some(project.to_relative(with_ext));
460        }
461        if base.is_file() {
462            return Some(project.to_relative(base));
463        }
464    }
465    None
466}
467
468/// C/C++: resolve #include "file.h" and <file.h>.
469/// Searches source dir, project root, include/, inc/, and src/.
470fn resolve_c_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
471    let source_dir = source_file.parent().unwrap_or(project.as_path());
472    let root = project.as_path();
473    let search_dirs = [
474        source_dir.to_path_buf(),
475        root.to_path_buf(),
476        root.join("include"),
477        root.join("inc"),
478        root.join("src"),
479    ];
480    for base_dir in &search_dirs {
481        let candidate = base_dir.join(module);
482        if candidate.is_file() {
483            return Some(project.to_relative(candidate));
484        }
485    }
486    None
487}
488
489/// PHP: use Namespace\Class -> Namespace/Class.php; require/include "file"
490/// Searches source dir, project root, src/, app/, and lib/.
491fn resolve_php_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
492    let by_namespace = module.replace('\\', "/");
493    let source_dir = source_file.parent().unwrap_or(project.as_path());
494    let root = project.as_path();
495
496    let search_dirs = [
497        source_dir.to_path_buf(),
498        root.to_path_buf(),
499        root.join("src"),
500        root.join("app"),
501        root.join("lib"),
502    ];
503    for base_dir in &search_dirs {
504        let with_php = if by_namespace.ends_with(".php") {
505            base_dir.join(&by_namespace)
506        } else {
507            base_dir.join(format!("{by_namespace}.php"))
508        };
509        if with_php.is_file() {
510            return Some(project.to_relative(with_php));
511        }
512        let as_is = base_dir.join(&by_namespace);
513        if as_is.is_file() {
514            return Some(project.to_relative(as_is));
515        }
516    }
517    None
518}
519
520fn resolve_csharp_module(project: &ProjectRoot, module: &str) -> Option<String> {
521    let as_path = module.replace('.', "/");
522    let candidate = project.as_path().join(format!("{as_path}.cs"));
523    if candidate.is_file() {
524        return Some(project.to_relative(candidate));
525    }
526    if let Some(last) = module.rsplit('.').next() {
527        let candidate = project.as_path().join(format!("{last}.cs"));
528        if candidate.is_file() {
529            return Some(project.to_relative(candidate));
530        }
531    }
532    None
533}
534
535fn resolve_dart_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
536    if let Some(stripped) = module.strip_prefix("package:") {
537        if let Some(slash_pos) = stripped.find('/') {
538            let rest = &stripped[slash_pos + 1..];
539            let candidate = project.as_path().join("lib").join(rest);
540            if candidate.is_file() {
541                return Some(project.to_relative(candidate));
542            }
543        }
544    } else {
545        let source_dir = source_file.parent().unwrap_or(project.as_path());
546        let candidate = source_dir.join(module);
547        if candidate.is_file() {
548            return Some(project.to_relative(candidate));
549        }
550    }
551    None
552}