1use std::collections::{HashMap, HashSet};
2use std::fs::File;
3use std::io::{BufRead, BufReader};
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7use rayon::prelude::*;
8use regex::Regex;
9
10use crate::models::ImportRelation;
11
12use super::helpers::{is_elixir_alias, is_ruby_constant_name};
13use super::predicates::{
14 csharp_declared_types, elixir_dependency_roots, java_declared_types, php_declared_symbols,
15 ruby_require_root,
16};
17
18#[derive(Debug, Clone, Default)]
19pub struct ImportResolutionContext {
20 pub(super) python_modules: HashSet<String>,
21 pub(super) js_external_packages: HashSet<String>,
22 pub(super) js_self_package_name: Option<String>,
23 pub(super) go_module_path: Option<String>,
24 pub(super) rust_external_crates: HashSet<String>,
25 pub(super) rust_self_crate_name: Option<String>,
26 pub(super) java_local_classes: HashSet<String>,
27 pub(super) csharp_local_roots: HashSet<String>,
28 pub(super) php_local_symbols: HashSet<String>,
29 pub(super) ruby_local_constant_roots: HashSet<String>,
30 pub(super) ruby_require_root_overrides: HashMap<String, String>,
31 pub(super) swift_local_modules: HashSet<String>,
32 pub(super) dart_external_packages: HashSet<String>,
33 pub(super) dart_self_package_name: Option<String>,
34 pub(super) elixir_external_roots: HashMap<String, String>,
35 pub(super) elixir_external_root_overrides: HashMap<String, String>,
36 pub(super) elixir_local_module_roots: HashSet<String>,
37}
38
39impl ImportResolutionContext {
40 pub(super) fn ruby_require_root(&self, required: &str) -> Option<&str> {
41 self.ruby_require_root_overrides
42 .get(required)
43 .map(String::as_str)
44 .or_else(|| ruby_require_root(required))
45 }
46
47 pub(super) fn elixir_external_root_module(&self, root: &str) -> Option<&str> {
48 self.elixir_external_root_overrides
49 .get(root)
50 .or_else(|| self.elixir_external_roots.get(root))
51 .map(String::as_str)
52 }
53}
54
55#[derive(Debug, Clone)]
56pub(crate) struct ExternalImportBinding {
57 pub(crate) module: String,
58 pub(crate) callee_name: String,
59}
60
61#[derive(Debug, Clone, Default)]
62pub(crate) struct ImportBindings {
63 pub(crate) bare: HashMap<String, ExternalImportBinding>,
64 pub(crate) bare_wildcard_modules: Vec<String>,
65 pub(crate) member: HashMap<String, String>,
66 pub(crate) external_roots: HashMap<String, ExternalRootBinding>,
67}
68
69#[derive(Debug, Clone)]
70pub(crate) struct ExternalRootBinding {
71 pub(crate) module: String,
72 pub(crate) module_from_qualifier: bool,
73}
74
75#[derive(Debug, Clone, Default)]
76pub(crate) struct ExtractedImports {
77 pub(crate) imports: Vec<ImportRelation>,
78 pub(crate) bindings: ImportBindings,
79}
80
81#[derive(Debug, Clone)]
82pub(crate) struct ExternalCallTarget {
83 pub(crate) module: String,
84 pub(crate) callee_name: String,
85}
86
87pub(super) const JS_BUILTIN_MODULES: &[&str] = &[
90 "assert",
91 "assert/strict",
92 "async_hooks",
93 "buffer",
94 "child_process",
95 "cluster",
96 "console",
97 "constants",
98 "crypto",
99 "dgram",
100 "diagnostics_channel",
101 "dns",
102 "dns/promises",
103 "domain",
104 "events",
105 "fs",
106 "fs/promises",
107 "http",
108 "http2",
109 "https",
110 "inspector",
111 "inspector/promises",
112 "net",
113 "module",
114 "os",
115 "path",
116 "path/posix",
117 "path/win32",
118 "perf_hooks",
119 "process",
120 "punycode",
121 "querystring",
122 "readline",
123 "readline/promises",
124 "repl",
125 "sea",
126 "stream",
127 "stream/consumers",
128 "stream/iter",
129 "stream/promises",
130 "stream/web",
131 "string_decoder",
132 "sqlite",
133 "sys",
134 "timers",
135 "timers/promises",
136 "test",
137 "test/reporters",
138 "tls",
139 "trace_events",
140 "tty",
141 "url",
142 "util",
143 "util/types",
144 "v8",
145 "vm",
146 "wasi",
147 "worker_threads",
148 "zlib",
149 "zlib/iter",
150];
151
152pub fn build_import_resolution_context(
153 root_path: &Path,
154 candidate_files: &[PathBuf],
155) -> ImportResolutionContext {
156 build_import_resolution_context_with_overrides(
157 root_path,
158 candidate_files,
159 HashMap::new(),
160 HashMap::new(),
161 )
162}
163
164pub fn build_import_resolution_context_with_overrides(
165 root_path: &Path,
166 candidate_files: &[PathBuf],
167 ruby_require_root_overrides: HashMap<String, String>,
168 elixir_external_root_overrides: HashMap<String, String>,
169) -> ImportResolutionContext {
170 ImportResolutionContext {
171 python_modules: build_python_module_index(root_path, candidate_files),
172 js_external_packages: load_js_external_packages(root_path),
173 js_self_package_name: load_js_self_package_name(root_path),
174 go_module_path: load_go_module_path(root_path),
175 rust_external_crates: load_rust_external_crates(root_path),
176 rust_self_crate_name: load_rust_self_crate_name(root_path),
177 java_local_classes: build_java_local_class_index(candidate_files),
178 csharp_local_roots: build_csharp_local_roots(candidate_files),
179 php_local_symbols: build_php_local_symbol_index(candidate_files),
180 ruby_local_constant_roots: build_ruby_local_constant_roots(candidate_files),
181 ruby_require_root_overrides,
182 swift_local_modules: build_swift_local_modules(root_path, candidate_files),
183 dart_external_packages: load_dart_external_packages(root_path),
184 dart_self_package_name: load_dart_self_package_name(root_path),
185 elixir_external_roots: load_elixir_external_roots(root_path),
186 elixir_external_root_overrides,
187 elixir_local_module_roots: build_elixir_local_module_roots(candidate_files),
188 }
189}
190
191pub(super) fn build_python_module_index(
192 root_path: &Path,
193 candidate_files: &[PathBuf],
194) -> HashSet<String> {
195 let mut modules = HashSet::new();
196
197 for path in candidate_files {
198 let Ok(rel) = path.strip_prefix(root_path) else {
199 continue;
200 };
201 let ext = rel
202 .extension()
203 .and_then(|ext| ext.to_str())
204 .unwrap_or_default()
205 .to_ascii_lowercase();
206 if !matches!(ext.as_str(), "py" | "pyi") {
207 continue;
208 }
209
210 let mut module = rel
211 .with_extension("")
212 .to_string_lossy()
213 .replace(['/', '\\'], ".");
214 if module.ends_with(".__init__") {
215 module.truncate(module.len() - ".__init__".len());
216 }
217 if module.is_empty() {
218 continue;
219 }
220 modules.insert(module.clone());
221
222 if let Some(stripped) = module.strip_prefix("src.") {
223 modules.insert(stripped.to_string());
224 }
225 }
226
227 modules
228}
229
230pub(super) fn load_js_external_packages(root_path: &Path) -> HashSet<String> {
231 let package_json = root_path.join("package.json");
232 let Ok(contents) = std::fs::read_to_string(package_json) else {
233 return HashSet::new();
234 };
235 let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
236 return HashSet::new();
237 };
238
239 let mut packages = HashSet::new();
240 for field in [
241 "dependencies",
242 "devDependencies",
243 "peerDependencies",
244 "optionalDependencies",
245 "bundledDependencies",
246 "bundleDependencies",
247 ] {
248 let Some(value) = json.get(field) else {
249 continue;
250 };
251 if let Some(map) = value.as_object() {
252 packages.extend(map.keys().cloned());
253 } else if let Some(array) = value.as_array() {
254 packages.extend(
255 array
256 .iter()
257 .filter_map(|value| value.as_str().map(str::to_owned)),
258 );
259 }
260 }
261 packages
262}
263
264pub(super) fn load_js_self_package_name(root_path: &Path) -> Option<String> {
265 let package_json = root_path.join("package.json");
266 let contents = std::fs::read_to_string(package_json).ok()?;
267 let json = serde_json::from_str::<serde_json::Value>(&contents).ok()?;
268 json.get("name")
269 .and_then(|value| value.as_str())
270 .map(ToOwned::to_owned)
271}
272
273pub(super) fn load_go_module_path(root_path: &Path) -> Option<String> {
274 let contents = std::fs::read_to_string(root_path.join("go.mod")).ok()?;
275 contents.lines().find_map(|line| {
276 let line = line.trim();
277 line.strip_prefix("module ")
278 .map(str::trim)
279 .filter(|module| !module.is_empty())
280 .map(ToOwned::to_owned)
281 })
282}
283
284pub(super) fn load_rust_external_crates(root_path: &Path) -> HashSet<String> {
285 let mut crates = HashSet::new();
286 for manifest in rust_manifest_paths(root_path) {
287 let Ok(contents) = std::fs::read_to_string(manifest) else {
288 continue;
289 };
290 let Ok(cargo_toml) = toml::from_str::<toml::Table>(&contents) else {
291 continue;
292 };
293
294 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
295 collect_rust_dependency_keys(cargo_toml.get(section), &mut crates);
296 }
297
298 if let Some(targets) = cargo_toml.get("target").and_then(toml::Value::as_table) {
299 for target in targets.values() {
300 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
301 collect_rust_dependency_keys(target.get(section), &mut crates);
302 }
303 }
304 }
305 }
306
307 crates
308}
309
310fn rust_manifest_paths(root_path: &Path) -> Vec<PathBuf> {
311 let root_manifest = root_path.join("Cargo.toml");
312 let mut manifests = vec![root_manifest.clone()];
313 let Ok(contents) = std::fs::read_to_string(&root_manifest) else {
314 return manifests;
315 };
316 let Ok(cargo_toml) = toml::from_str::<toml::Table>(&contents) else {
317 return manifests;
318 };
319 let Some(members) = cargo_toml
320 .get("workspace")
321 .and_then(|workspace| workspace.get("members"))
322 .and_then(toml::Value::as_array)
323 else {
324 return manifests;
325 };
326 for member in members.iter().filter_map(toml::Value::as_str) {
327 if member.contains('*') {
328 let pattern = root_path.join(member).join("Cargo.toml");
329 let Some(pattern) = pattern.to_str() else {
330 continue;
331 };
332 let Ok(entries) = glob::glob(pattern) else {
333 log::debug!(
334 "invalid Cargo workspace glob member `{member}` under {}",
335 root_path.display()
336 );
337 continue;
338 };
339 manifests.extend(entries.flatten().filter(|path| path.is_file()));
340 continue;
341 }
342 let manifest = root_path.join(member).join("Cargo.toml");
343 if manifest.is_file() {
344 manifests.push(manifest);
345 }
346 }
347 manifests.sort();
348 manifests.dedup();
349 manifests
350}
351
352pub(super) fn load_rust_self_crate_name(root_path: &Path) -> Option<String> {
353 let contents = std::fs::read_to_string(root_path.join("Cargo.toml")).ok()?;
354 let cargo_toml = toml::from_str::<toml::Table>(&contents).ok()?;
355 cargo_toml
356 .get("package")
357 .and_then(|package| package.get("name"))
358 .and_then(toml::Value::as_str)
359 .map(normalize_rust_crate_name)
360 .filter(|name| !name.is_empty())
361}
362
363pub(super) fn collect_rust_dependency_keys(
364 value: Option<&toml::Value>,
365 crates: &mut HashSet<String>,
366) {
367 let Some(table) = value.and_then(toml::Value::as_table) else {
368 return;
369 };
370 for name in table.keys() {
371 let name = normalize_rust_crate_name(name);
372 if !name.is_empty() {
373 crates.insert(name);
374 }
375 }
376}
377
378pub(super) fn normalize_rust_crate_name(name: &str) -> String {
379 name.trim().replace('-', "_")
380}
381
382pub(super) fn build_java_local_class_index(candidate_files: &[PathBuf]) -> HashSet<String> {
383 candidate_files
384 .par_iter()
385 .map(|path| {
386 let mut classes = HashSet::new();
387 let ext = path
388 .extension()
389 .and_then(|ext| ext.to_str())
390 .unwrap_or_default();
391 if ext != "java" {
392 return classes;
393 }
394 let Ok(file) = File::open(path) else {
395 return classes;
396 };
397 let mut package = None;
398 for line in BufReader::new(file).lines().map_while(Result::ok) {
399 let line = line.trim();
400 if package.is_none() {
401 package = line
402 .strip_prefix("package ")
403 .map(|rest| rest.trim().trim_end_matches(';').trim().to_string());
404 }
405 for class_name in java_declared_types(line) {
406 classes.insert(class_name.clone());
407 if let Some(package) = package.as_deref()
408 && !package.is_empty()
409 {
410 classes.insert(format!("{package}.{class_name}"));
411 }
412 }
413 }
414 classes
415 })
416 .reduce(HashSet::new, |mut all, classes| {
417 all.extend(classes);
418 all
419 })
420}
421
422pub(super) fn build_csharp_local_roots(candidate_files: &[PathBuf]) -> HashSet<String> {
423 candidate_files
424 .par_iter()
425 .map(|path| {
426 let mut roots = HashSet::new();
427 let ext = path
428 .extension()
429 .and_then(|ext| ext.to_str())
430 .unwrap_or_default();
431 if ext != "cs" {
432 return roots;
433 }
434 let Ok(file) = File::open(path) else {
435 return roots;
436 };
437 for line in BufReader::new(file).lines().map_while(Result::ok) {
438 let line = line.trim();
439 if let Some(rest) = line.strip_prefix("namespace ") {
440 let namespace = rest
441 .trim()
442 .trim_end_matches([';', '{'])
443 .split_whitespace()
444 .next()
445 .unwrap_or_default();
446 if let Some(root) = namespace.split('.').next()
447 && !root.is_empty()
448 {
449 roots.insert(root.to_string());
450 }
451 }
452 for type_name in csharp_declared_types(line) {
453 roots.insert(type_name);
454 }
455 }
456 roots
457 })
458 .reduce(HashSet::new, |mut all, roots| {
459 all.extend(roots);
460 all
461 })
462}
463
464pub(super) fn build_php_local_symbol_index(candidate_files: &[PathBuf]) -> HashSet<String> {
465 candidate_files
466 .par_iter()
467 .map(|path| {
468 let mut symbols = HashSet::new();
469 let ext = path
470 .extension()
471 .and_then(|ext| ext.to_str())
472 .unwrap_or_default();
473 if ext != "php" {
474 return symbols;
475 }
476 let Ok(file) = File::open(path) else {
477 return symbols;
478 };
479 let mut namespace = None;
480 for line in BufReader::new(file).lines().map_while(Result::ok) {
481 let line = line.trim();
482 if namespace.is_none() {
483 namespace = line
484 .strip_prefix("namespace ")
485 .map(|rest| rest.trim().trim_end_matches([';', '{']).to_string());
486 }
487 for name in php_declared_symbols(line) {
488 symbols.insert(name.clone());
489 symbols.insert(name.to_ascii_lowercase());
490 if let Some(namespace) = namespace.as_deref()
491 && !namespace.is_empty()
492 {
493 let qualified = format!("{namespace}\\{name}");
494 symbols.insert(qualified.clone());
495 symbols.insert(qualified.to_ascii_lowercase());
496 }
497 }
498 }
499 symbols
500 })
501 .reduce(HashSet::new, |mut all, symbols| {
502 all.extend(symbols);
503 all
504 })
505}
506
507pub(super) fn build_ruby_local_constant_roots(candidate_files: &[PathBuf]) -> HashSet<String> {
508 candidate_files
509 .par_iter()
510 .map(|path| {
511 let mut roots = HashSet::new();
512 let ext = path
513 .extension()
514 .and_then(|ext| ext.to_str())
515 .unwrap_or_default();
516 if !matches!(ext, "rb" | "rake" | "gemspec") {
517 return roots;
518 }
519 let Ok(file) = File::open(path) else {
520 return roots;
521 };
522 for line in BufReader::new(file).lines().map_while(Result::ok) {
523 let line = line.trim_start();
524 let Some(rest) = line
525 .strip_prefix("class ")
526 .or_else(|| line.strip_prefix("module "))
527 else {
528 continue;
529 };
530 let name = rest
531 .split(|ch: char| ch.is_whitespace() || matches!(ch, '<' | '(' | ';' | '#'))
532 .next()
533 .unwrap_or_default()
534 .trim_start_matches("::");
535 if let Some(root) = name.split("::").next()
536 && is_ruby_constant_name(root)
537 {
538 roots.insert(root.to_string());
539 }
540 }
541 roots
542 })
543 .reduce(HashSet::new, |mut all, roots| {
544 all.extend(roots);
545 all
546 })
547}
548
549pub(super) fn build_swift_local_modules(
550 root_path: &Path,
551 candidate_files: &[PathBuf],
552) -> HashSet<String> {
553 candidate_files
554 .par_iter()
555 .map(|path| {
556 let mut modules = HashSet::new();
557 let ext = path
558 .extension()
559 .and_then(|ext| ext.to_str())
560 .unwrap_or_default();
561 if ext != "swift" {
562 return modules;
563 }
564 let rel = path.strip_prefix(root_path).unwrap_or(path.as_path());
565 let components = rel
566 .components()
567 .filter_map(|component| component.as_os_str().to_str())
568 .collect::<Vec<_>>();
569 for window in components.windows(2) {
570 if matches!(window[0], "Sources" | "Tests") && !window[1].is_empty() {
571 modules.insert(window[1].to_string());
572 }
573 }
574 if let Some(parent) = rel
575 .parent()
576 .and_then(Path::file_name)
577 .and_then(|name| name.to_str())
578 && !parent.is_empty()
579 && parent != "Sources"
580 && parent != "Tests"
581 {
582 modules.insert(parent.to_string());
583 }
584 modules
585 })
586 .reduce(HashSet::new, |mut all, modules| {
587 all.extend(modules);
588 all
589 })
590}
591
592pub(super) fn load_dart_external_packages(root_path: &Path) -> HashSet<String> {
593 let Ok(contents) = std::fs::read_to_string(root_path.join("pubspec.yaml")) else {
594 return HashSet::new();
595 };
596 let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents) else {
597 return HashSet::new();
598 };
599
600 let mut packages = HashSet::new();
601 for field in ["dependencies", "dev_dependencies", "dependency_overrides"] {
602 if let Some(map) = yaml.get(field).and_then(|value| value.as_mapping()) {
603 for key in map.keys().filter_map(|key| key.as_str()) {
604 if !key.is_empty() && key != "sdk" {
605 packages.insert(key.to_string());
606 }
607 }
608 }
609 }
610 packages
611}
612
613pub(super) fn load_dart_self_package_name(root_path: &Path) -> Option<String> {
614 let contents = std::fs::read_to_string(root_path.join("pubspec.yaml")).ok()?;
615 let yaml = serde_yaml::from_str::<serde_yaml::Value>(&contents).ok()?;
616 yaml.get("name")
617 .and_then(|value| value.as_str())
618 .map(ToOwned::to_owned)
619}
620
621pub(super) fn build_elixir_local_module_roots(candidate_files: &[PathBuf]) -> HashSet<String> {
622 candidate_files
623 .par_iter()
624 .map(|path| {
625 let mut roots = HashSet::new();
626 let ext = path
627 .extension()
628 .and_then(|ext| ext.to_str())
629 .unwrap_or_default();
630 if !matches!(ext, "ex" | "exs") {
631 return roots;
632 }
633 let Ok(file) = File::open(path) else {
634 return roots;
635 };
636 for line in BufReader::new(file).lines().map_while(Result::ok) {
637 let line = line.trim_start();
638 let Some(rest) = line.strip_prefix("defmodule ") else {
639 continue;
640 };
641 let module = rest
642 .split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | '(' | '['))
643 .next()
644 .unwrap_or_default();
645 if let Some(root) = module.split('.').next()
646 && is_elixir_alias(root)
647 {
648 roots.insert(root.to_string());
649 }
650 }
651 roots
652 })
653 .reduce(HashSet::new, |mut all, roots| {
654 all.extend(roots);
655 all
656 })
657}
658
659pub(super) fn load_elixir_external_roots(root_path: &Path) -> HashMap<String, String> {
660 let deps = load_elixir_dependency_names(root_path);
661 let mut roots = HashMap::new();
662 for dep in deps {
663 if let Some(dep_roots) = elixir_dependency_roots(&dep) {
664 for root in dep_roots {
665 roots.insert(root.clone(), root.clone());
666 }
667 }
668 }
669 roots
670}
671
672pub(super) fn load_elixir_dependency_names(root_path: &Path) -> HashSet<String> {
673 let mut deps = HashSet::new();
674 if let Ok(contents) = std::fs::read_to_string(root_path.join("mix.exs")) {
675 for captures in elixir_mix_dependency_regex().captures_iter(&contents) {
678 if let Some(dep) = captures.get(1) {
679 deps.insert(dep.as_str().to_string());
680 }
681 }
682 }
683 if let Ok(contents) = std::fs::read_to_string(root_path.join("mix.lock")) {
684 for captures in elixir_lock_dependency_regex().captures_iter(&contents) {
687 if let Some(dep) = captures.get(1) {
688 deps.insert(dep.as_str().to_string());
689 }
690 }
691 }
692 deps
693}
694
695fn elixir_mix_dependency_regex() -> &'static Regex {
696 static REGEX: OnceLock<Regex> = OnceLock::new();
697 REGEX.get_or_init(|| {
698 Regex::new(r"\{\s*:([A-Za-z_][A-Za-z0-9_]*)\b").expect("Elixir dependency regex compiles")
699 })
700}
701
702fn elixir_lock_dependency_regex() -> &'static Regex {
703 static REGEX: OnceLock<Regex> = OnceLock::new();
704 REGEX.get_or_init(|| {
705 Regex::new(r#""([A-Za-z_][A-Za-z0-9_]*)"\s*:"#)
706 .expect("Elixir lock dependency regex compiles")
707 })
708}