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
257    let needs_stripped_candidates = if let Some(ext) = base.extension().and_then(|e| e.to_str()) {
258        matches!(ext, "js" | "jsx" | "mjs" | "cjs")
259    } else {
260        true
261    };
262
263    if needs_stripped_candidates {
264        let stripped = if base.extension().is_some() {
265            base.with_extension("")
266        } else {
267            base.to_path_buf()
268        };
269        for ext in extensions {
270            candidates.push(stripped.with_extension(ext));
271        }
272        for ext in extensions {
273            candidates.push(stripped.join(format!("index.{ext}")));
274        }
275    }
276    candidates
277}
278
279/// Go: resolve import path by stripping go.mod module prefix, then searching project dirs.
280fn resolve_go_module(project: &ProjectRoot, module: &str) -> Option<String> {
281    // Skip stdlib (no dots in first segment = stdlib)
282    if !module.contains('.') {
283        return None;
284    }
285
286    let root = project.as_path();
287
288    // Try to read go.mod module path and strip it
289    let module_prefix = std::fs::read_to_string(root.join("go.mod"))
290        .ok()
291        .and_then(|content| {
292            content
293                .lines()
294                .find(|l| l.starts_with("module "))
295                .map(|l| l.trim_start_matches("module ").trim().to_string())
296        });
297
298    // If import starts with module prefix, strip it to get relative path
299    let relative = if let Some(ref prefix) = module_prefix {
300        module
301            .strip_prefix(prefix)
302            .map(|s| s.trim_start_matches('/'))
303    } else {
304        None
305    };
306
307    // Search candidates: stripped path first, then last segment fallback
308    let candidates: Vec<&str> = if let Some(rel) = relative {
309        vec![rel]
310    } else {
311        // Fallback: try full path and last segment
312        let last = module.split('/').next_back().unwrap_or(module);
313        vec![module, last]
314    };
315
316    for candidate in candidates {
317        let dir = root.join(candidate);
318        if dir.is_dir() {
319            // Return first .go file in the directory
320            if let Ok(rd) = std::fs::read_dir(&dir) {
321                for entry in rd.flatten() {
322                    if entry.path().extension().and_then(|e| e.to_str()) == Some("go") {
323                        return Some(project.to_relative(entry.path()));
324                    }
325                }
326            }
327        }
328        let file = root.join(format!("{candidate}.go"));
329        if file.is_file() {
330            return Some(project.to_relative(file));
331        }
332    }
333    None
334}
335
336/// Java/Kotlin: convert fully-qualified class name to file path.
337fn resolve_jvm_module(project: &ProjectRoot, module: &str) -> Option<String> {
338    let path_part = module.replace('.', "/");
339    for ext in ["java", "kt"] {
340        let candidate = project.as_path().join(format!("{path_part}.{ext}"));
341        if candidate.is_file() {
342            return Some(project.to_relative(candidate));
343        }
344        for prefix in ["src/main/java", "src/main/kotlin", "src"] {
345            let candidate = project
346                .as_path()
347                .join(prefix)
348                .join(format!("{path_part}.{ext}"));
349            if candidate.is_file() {
350                return Some(project.to_relative(candidate));
351            }
352        }
353    }
354    None
355}
356
357/// Find the `src/` directory of a workspace crate given the crate name (using underscores).
358pub(super) fn find_workspace_crate_dir(project: &ProjectRoot, crate_name: &str) -> Option<PathBuf> {
359    let crates_dir = project.as_path().join("crates");
360    if !crates_dir.is_dir() {
361        return None;
362    }
363    for entry in std::fs::read_dir(&crates_dir).ok()?.flatten() {
364        let cargo_toml = entry.path().join("Cargo.toml");
365        if cargo_toml.is_file() {
366            let dir_name = entry.file_name().to_string_lossy().replace('-', "_");
367            if dir_name == crate_name {
368                return Some(entry.path().join("src"));
369            }
370        }
371    }
372    None
373}
374
375/// Rust: `use crate::foo::bar` -> look for src/foo/bar.rs or src/foo/bar/mod.rs.
376///       `mod foo;` -> look for foo.rs or foo/mod.rs relative to source dir.
377///       `use codelens_engine::ProjectRoot` -> strip workspace crate prefix and look in that crate's src/.
378fn resolve_rust_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
379    let stripped = module
380        .trim_start_matches("crate::")
381        .trim_start_matches("super::")
382        .trim_start_matches("self::");
383
384    // Check if the first segment matches a known workspace crate name.
385    let segments: Vec<&str> = stripped.splitn(2, "::").collect();
386    if segments.len() == 2 {
387        let first_seg = segments[0];
388        if let Some(crate_src) = find_workspace_crate_dir(project, first_seg) {
389            let remaining = segments[1].replace("::", "/");
390            let mut parts: Vec<&str> = remaining.split('/').collect();
391            while !parts.is_empty() {
392                let candidate_path = parts.join("/");
393                for candidate in [
394                    crate_src.join(format!("{candidate_path}.rs")),
395                    crate_src.join(&candidate_path).join("mod.rs"),
396                ] {
397                    if candidate.is_file() {
398                        return Some(project.to_relative(candidate));
399                    }
400                }
401                parts.pop();
402            }
403        }
404    }
405
406    let path_part = stripped.replace("::", "/");
407
408    let mut parts: Vec<&str> = path_part.split('/').collect();
409    while !parts.is_empty() {
410        let candidate_path = parts.join("/");
411        if let Some(parent) = source_file.parent() {
412            for candidate in [
413                parent.join(format!("{candidate_path}.rs")),
414                parent.join(&candidate_path).join("mod.rs"),
415            ] {
416                if candidate.is_file() {
417                    return Some(project.to_relative(candidate));
418                }
419            }
420        }
421        let src = project.as_path().join("src");
422        for candidate in [
423            src.join(format!("{candidate_path}.rs")),
424            src.join(&candidate_path).join("mod.rs"),
425        ] {
426            if candidate.is_file() {
427                return Some(project.to_relative(candidate));
428            }
429        }
430        if let Ok(entries) = std::fs::read_dir(project.as_path().join("crates")) {
431            for entry in entries.flatten() {
432                let crate_src = entry.path().join("src");
433                for candidate in [
434                    crate_src.join(format!("{candidate_path}.rs")),
435                    crate_src.join(&candidate_path).join("mod.rs"),
436                ] {
437                    if candidate.is_file() {
438                        return Some(project.to_relative(candidate));
439                    }
440                }
441            }
442        }
443        parts.pop();
444    }
445    None
446}
447
448/// Ruby: resolve require/require_relative paths to .rb files.
449/// Searches source dir, project root, lib/, and app/ (Rails convention).
450fn resolve_ruby_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
451    let source_dir = source_file.parent().unwrap_or(project.as_path());
452    let root = project.as_path();
453
454    let search_dirs: Vec<PathBuf> = if module.starts_with('.') {
455        vec![source_dir.to_path_buf()]
456    } else {
457        vec![root.to_path_buf(), root.join("lib"), root.join("app")]
458    };
459
460    for base_dir in &search_dirs {
461        if !base_dir.is_dir() {
462            continue;
463        }
464        let base = base_dir.join(module);
465        let with_ext = if base.extension().is_some() {
466            base.clone()
467        } else {
468            base.with_extension("rb")
469        };
470        if with_ext.is_file() {
471            return Some(project.to_relative(with_ext));
472        }
473        if base.is_file() {
474            return Some(project.to_relative(base));
475        }
476    }
477    None
478}
479
480/// C/C++: resolve #include "file.h" and <file.h>.
481/// Searches source dir, project root, include/, inc/, and src/.
482fn resolve_c_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
483    let source_dir = source_file.parent().unwrap_or(project.as_path());
484    let root = project.as_path();
485    let search_dirs = [
486        source_dir.to_path_buf(),
487        root.to_path_buf(),
488        root.join("include"),
489        root.join("inc"),
490        root.join("src"),
491    ];
492    for base_dir in &search_dirs {
493        let candidate = base_dir.join(module);
494        if candidate.is_file() {
495            return Some(project.to_relative(candidate));
496        }
497    }
498    None
499}
500
501/// PHP: use Namespace\Class -> Namespace/Class.php; require/include "file"
502/// Searches source dir, project root, src/, app/, and lib/.
503fn resolve_php_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
504    let by_namespace = module.replace('\\', "/");
505    let source_dir = source_file.parent().unwrap_or(project.as_path());
506    let root = project.as_path();
507
508    let search_dirs = [
509        source_dir.to_path_buf(),
510        root.to_path_buf(),
511        root.join("src"),
512        root.join("app"),
513        root.join("lib"),
514    ];
515    for base_dir in &search_dirs {
516        let with_php = if by_namespace.ends_with(".php") {
517            base_dir.join(&by_namespace)
518        } else {
519            base_dir.join(format!("{by_namespace}.php"))
520        };
521        if with_php.is_file() {
522            return Some(project.to_relative(with_php));
523        }
524        let as_is = base_dir.join(&by_namespace);
525        if as_is.is_file() {
526            return Some(project.to_relative(as_is));
527        }
528    }
529    None
530}
531
532fn resolve_csharp_module(project: &ProjectRoot, module: &str) -> Option<String> {
533    let as_path = module.replace('.', "/");
534    let candidate = project.as_path().join(format!("{as_path}.cs"));
535    if candidate.is_file() {
536        return Some(project.to_relative(candidate));
537    }
538    if let Some(last) = module.rsplit('.').next() {
539        let candidate = project.as_path().join(format!("{last}.cs"));
540        if candidate.is_file() {
541            return Some(project.to_relative(candidate));
542        }
543    }
544    None
545}
546
547fn resolve_dart_module(project: &ProjectRoot, source_file: &Path, module: &str) -> Option<String> {
548    if let Some(stripped) = module.strip_prefix("package:") {
549        if let Some(slash_pos) = stripped.find('/') {
550            let rest = &stripped[slash_pos + 1..];
551            let candidate = project.as_path().join("lib").join(rest);
552            if candidate.is_file() {
553                return Some(project.to_relative(candidate));
554            }
555        }
556    } else {
557        let source_dir = source_file.parent().unwrap_or(project.as_path());
558        let candidate = source_dir.join(module);
559        if candidate.is_file() {
560            return Some(project.to_relative(candidate));
561        }
562    }
563    None
564}