codelens_engine/import_graph/
resolvers.rs1use crate::project::ProjectRoot;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::{LazyLock, Mutex};
5
6pub(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
36pub 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
45const 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 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 for _ in 1..dots {
64 base = base.parent()?.to_path_buf();
65 }
66 if remainder.is_empty() {
67 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 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 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 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
132fn 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 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 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#[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 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 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 if !module.starts_with('.') && !module.starts_with('/') {
236 return None;
237 }
238
239 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
267fn resolve_go_module(project: &ProjectRoot, module: &str) -> Option<String> {
269 if !module.contains('.') {
271 return None;
272 }
273
274 let root = project.as_path();
275
276 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 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 let candidates: Vec<&str> = if let Some(rel) = relative {
297 vec![rel]
298 } else {
299 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 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
324fn 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
345pub(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
363fn 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 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
436fn 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
468fn 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
489fn 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}