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.to_ascii_lowercase());
489 if let Some(namespace) = namespace.as_deref()
490 && !namespace.is_empty()
491 {
492 let qualified = format!("{namespace}\\{name}");
493 symbols.insert(qualified.to_ascii_lowercase());
494 }
495 }
496 }
497 symbols
498 })
499 .reduce(HashSet::new, |mut all, symbols| {
500 all.extend(symbols);
501 all
502 })
503}
504
505pub(super) fn build_ruby_local_constant_roots(candidate_files: &[PathBuf]) -> HashSet<String> {
506 candidate_files
507 .par_iter()
508 .map(|path| {
509 let mut roots = HashSet::new();
510 let ext = path
511 .extension()
512 .and_then(|ext| ext.to_str())
513 .unwrap_or_default();
514 if !matches!(ext, "rb" | "rake" | "gemspec") {
515 return roots;
516 }
517 let Ok(file) = File::open(path) else {
518 return roots;
519 };
520 for line in BufReader::new(file).lines().map_while(Result::ok) {
521 let line = line.trim_start();
522 let Some(rest) = line
523 .strip_prefix("class ")
524 .or_else(|| line.strip_prefix("module "))
525 else {
526 continue;
527 };
528 let name = rest
529 .split(|ch: char| ch.is_whitespace() || matches!(ch, '<' | '(' | ';' | '#'))
530 .next()
531 .unwrap_or_default()
532 .trim_start_matches("::");
533 if let Some(root) = name.split("::").next()
534 && is_ruby_constant_name(root)
535 {
536 roots.insert(root.to_string());
537 }
538 }
539 roots
540 })
541 .reduce(HashSet::new, |mut all, roots| {
542 all.extend(roots);
543 all
544 })
545}
546
547pub(super) fn build_swift_local_modules(
548 root_path: &Path,
549 candidate_files: &[PathBuf],
550) -> HashSet<String> {
551 candidate_files
552 .par_iter()
553 .map(|path| {
554 let mut modules = HashSet::new();
555 let ext = path
556 .extension()
557 .and_then(|ext| ext.to_str())
558 .unwrap_or_default();
559 if ext != "swift" {
560 return modules;
561 }
562 let rel = path.strip_prefix(root_path).unwrap_or(path.as_path());
563 let components = rel
564 .components()
565 .filter_map(|component| component.as_os_str().to_str())
566 .collect::<Vec<_>>();
567 for window in components.windows(2) {
568 if matches!(window[0], "Sources" | "Tests") && !window[1].is_empty() {
569 modules.insert(window[1].to_string());
570 }
571 }
572 if let Some(parent) = rel
573 .parent()
574 .and_then(Path::file_name)
575 .and_then(|name| name.to_str())
576 && !parent.is_empty()
577 && parent != "Sources"
578 && parent != "Tests"
579 {
580 modules.insert(parent.to_string());
581 }
582 modules
583 })
584 .reduce(HashSet::new, |mut all, modules| {
585 all.extend(modules);
586 all
587 })
588}
589
590pub(super) fn load_dart_external_packages(root_path: &Path) -> HashSet<String> {
591 let Ok(contents) = std::fs::read_to_string(root_path.join("pubspec.yaml")) else {
592 return HashSet::new();
593 };
594 let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents) else {
595 return HashSet::new();
596 };
597
598 let mut packages = HashSet::new();
599 for field in ["dependencies", "dev_dependencies", "dependency_overrides"] {
600 if let Some(map) = yaml.get(field).and_then(|value| value.as_mapping()) {
601 for key in map.keys().filter_map(|key| key.as_str()) {
602 if !key.is_empty() && key != "sdk" {
603 packages.insert(key.to_string());
604 }
605 }
606 }
607 }
608 packages
609}
610
611pub(super) fn load_dart_self_package_name(root_path: &Path) -> Option<String> {
612 let contents = std::fs::read_to_string(root_path.join("pubspec.yaml")).ok()?;
613 let yaml = serde_yaml::from_str::<serde_yaml::Value>(&contents).ok()?;
614 yaml.get("name")
615 .and_then(|value| value.as_str())
616 .map(ToOwned::to_owned)
617}
618
619pub(super) fn build_elixir_local_module_roots(candidate_files: &[PathBuf]) -> HashSet<String> {
620 candidate_files
621 .par_iter()
622 .map(|path| {
623 let mut roots = HashSet::new();
624 let ext = path
625 .extension()
626 .and_then(|ext| ext.to_str())
627 .unwrap_or_default();
628 if !matches!(ext, "ex" | "exs") {
629 return roots;
630 }
631 let Ok(file) = File::open(path) else {
632 return roots;
633 };
634 for line in BufReader::new(file).lines().map_while(Result::ok) {
635 let line = line.trim_start();
636 let Some(rest) = line.strip_prefix("defmodule ") else {
637 continue;
638 };
639 let module = rest
640 .split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | '(' | '['))
641 .next()
642 .unwrap_or_default();
643 if let Some(root) = module.split('.').next()
644 && is_elixir_alias(root)
645 {
646 roots.insert(root.to_string());
647 }
648 }
649 roots
650 })
651 .reduce(HashSet::new, |mut all, roots| {
652 all.extend(roots);
653 all
654 })
655}
656
657pub(super) fn load_elixir_external_roots(root_path: &Path) -> HashMap<String, String> {
658 let deps = load_elixir_dependency_names(root_path);
659 let mut roots = HashMap::new();
660 for dep in deps {
661 if let Some(dep_roots) = elixir_dependency_roots(&dep) {
662 for root in dep_roots {
663 roots.insert(root.clone(), root.clone());
664 }
665 }
666 }
667 roots
668}
669
670pub(super) fn load_elixir_dependency_names(root_path: &Path) -> HashSet<String> {
671 let mut deps = HashSet::new();
672 if let Ok(contents) = std::fs::read_to_string(root_path.join("mix.exs")) {
673 for captures in elixir_mix_dependency_regex().captures_iter(&contents) {
676 if let Some(dep) = captures.get(1) {
677 deps.insert(dep.as_str().to_string());
678 }
679 }
680 }
681 if let Ok(contents) = std::fs::read_to_string(root_path.join("mix.lock")) {
682 for captures in elixir_lock_dependency_regex().captures_iter(&contents) {
685 if let Some(dep) = captures.get(1) {
686 deps.insert(dep.as_str().to_string());
687 }
688 }
689 }
690 deps
691}
692
693fn elixir_mix_dependency_regex() -> &'static Regex {
694 static REGEX: OnceLock<Regex> = OnceLock::new();
695 REGEX.get_or_init(|| {
696 Regex::new(r"\{\s*:([A-Za-z_][A-Za-z0-9_]*)\b").expect("Elixir dependency regex compiles")
697 })
698}
699
700fn elixir_lock_dependency_regex() -> &'static Regex {
701 static REGEX: OnceLock<Regex> = OnceLock::new();
702 REGEX.get_or_init(|| {
703 Regex::new(r#""([A-Za-z_][A-Za-z0-9_]*)"\s*:"#)
704 .expect("Elixir lock dependency regex compiles")
705 })
706}