1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use crate::models::{ImportRelation, Symbol};
6
7#[derive(Debug, Clone, Default)]
8pub struct ImportResolutionContext {
9 python_modules: HashSet<String>,
10 js_external_packages: HashSet<String>,
11 js_self_package_name: Option<String>,
12 go_module_path: Option<String>,
13 rust_external_crates: HashSet<String>,
14 rust_self_crate_name: Option<String>,
15 java_local_classes: HashSet<String>,
16 csharp_local_roots: HashSet<String>,
17 php_local_symbols: HashSet<String>,
18 ruby_local_constant_roots: HashSet<String>,
19 ruby_require_root_overrides: HashMap<String, String>,
20 dart_external_packages: HashSet<String>,
21 dart_self_package_name: Option<String>,
22 elixir_external_roots: HashMap<String, String>,
23 elixir_external_root_overrides: HashMap<String, String>,
24 elixir_local_module_roots: HashSet<String>,
25}
26
27impl ImportResolutionContext {
28 fn ruby_require_root(&self, required: &str) -> Option<&str> {
29 self.ruby_require_root_overrides
30 .get(required)
31 .map(String::as_str)
32 .or_else(|| ruby_require_root(required))
33 }
34
35 fn elixir_external_root_module(&self, root: &str) -> Option<&str> {
36 self.elixir_external_root_overrides
37 .get(root)
38 .or_else(|| self.elixir_external_roots.get(root))
39 .map(String::as_str)
40 }
41}
42
43#[derive(Debug, Clone)]
44pub(crate) struct ExternalImportBinding {
45 pub(crate) module: String,
46 pub(crate) callee_name: String,
47}
48
49#[derive(Debug, Clone, Default)]
50pub(crate) struct ImportBindings {
51 pub(crate) bare: HashMap<String, ExternalImportBinding>,
52 pub(crate) bare_wildcard_modules: Vec<String>,
53 pub(crate) member: HashMap<String, String>,
54 pub(crate) external_roots: HashMap<String, ExternalRootBinding>,
55}
56
57#[derive(Debug, Clone)]
58pub(crate) struct ExternalRootBinding {
59 pub(crate) module: String,
60 pub(crate) module_from_qualifier: bool,
61}
62
63#[derive(Debug, Clone, Default)]
64pub(crate) struct ExtractedImports {
65 pub(crate) imports: Vec<ImportRelation>,
66 pub(crate) bindings: ImportBindings,
67}
68
69#[derive(Debug, Clone)]
70pub(crate) struct ExternalCallTarget {
71 pub(crate) module: String,
72 pub(crate) callee_name: String,
73}
74
75const JS_BUILTIN_MODULES: &[&str] = &[
76 "assert",
77 "buffer",
78 "child_process",
79 "cluster",
80 "crypto",
81 "dgram",
82 "dns",
83 "domain",
84 "events",
85 "fs",
86 "http",
87 "https",
88 "net",
89 "os",
90 "path",
91 "perf_hooks",
92 "process",
93 "punycode",
94 "querystring",
95 "readline",
96 "repl",
97 "stream",
98 "string_decoder",
99 "timers",
100 "tls",
101 "trace_events",
102 "tty",
103 "url",
104 "util",
105 "v8",
106 "vm",
107 "worker_threads",
108 "zlib",
109];
110
111pub fn build_import_resolution_context(
112 root_path: &Path,
113 candidate_files: &[PathBuf],
114) -> ImportResolutionContext {
115 build_import_resolution_context_with_overrides(
116 root_path,
117 candidate_files,
118 HashMap::new(),
119 HashMap::new(),
120 )
121}
122
123pub fn build_import_resolution_context_with_overrides(
124 root_path: &Path,
125 candidate_files: &[PathBuf],
126 ruby_require_root_overrides: HashMap<String, String>,
127 elixir_external_root_overrides: HashMap<String, String>,
128) -> ImportResolutionContext {
129 ImportResolutionContext {
130 python_modules: build_python_module_index(root_path, candidate_files),
131 js_external_packages: load_js_external_packages(root_path),
132 js_self_package_name: load_js_self_package_name(root_path),
133 go_module_path: load_go_module_path(root_path),
134 rust_external_crates: load_rust_external_crates(root_path),
135 rust_self_crate_name: load_rust_self_crate_name(root_path),
136 java_local_classes: build_java_local_class_index(candidate_files),
137 csharp_local_roots: build_csharp_local_roots(candidate_files),
138 php_local_symbols: build_php_local_symbol_index(candidate_files),
139 ruby_local_constant_roots: build_ruby_local_constant_roots(candidate_files),
140 ruby_require_root_overrides,
141 dart_external_packages: load_dart_external_packages(root_path),
142 dart_self_package_name: load_dart_self_package_name(root_path),
143 elixir_external_roots: load_elixir_external_roots(root_path),
144 elixir_external_root_overrides,
145 elixir_local_module_roots: build_elixir_local_module_roots(candidate_files),
146 }
147}
148
149pub(crate) fn parse_import_statement(
150 language: &str,
151 text: &str,
152 rel_path: &str,
153 import_context: &ImportResolutionContext,
154 extracted: &mut ExtractedImports,
155) {
156 match language {
157 "python" => parse_python_import_statement(text, rel_path, import_context, extracted),
158 "javascript" | "typescript" => {
159 parse_js_import_statement(text, rel_path, import_context, extracted)
160 }
161 "go" => parse_go_import_statement(text, rel_path, import_context, extracted),
162 "rust" => parse_rust_import_statement(text, rel_path, import_context, extracted),
163 "java" => parse_java_import_statement(text, rel_path, import_context, extracted),
164 "csharp" => parse_csharp_import_statement(text, rel_path, import_context, extracted),
165 "php" => parse_php_import_statement(text, rel_path, import_context, extracted),
166 "swift" => parse_swift_import_statement(text, rel_path, extracted),
167 "ruby" => parse_ruby_import_statement(text, rel_path, import_context, extracted),
168 "dart" => parse_dart_import_statement(text, rel_path, import_context, extracted),
169 "elixir" => parse_elixir_import_statement(text, rel_path, import_context, extracted),
170 _ => extracted.imports.push(ImportRelation {
171 file_path: rel_path.to_string(),
172 module_name: text.to_string(),
173 }),
174 }
175}
176
177pub(crate) fn seed_import_bindings(
178 language: &str,
179 import_context: &ImportResolutionContext,
180 bindings: &mut ImportBindings,
181) {
182 match language {
183 "rust" => {
184 for root in rust_external_roots(import_context) {
185 bindings.external_roots.insert(
186 root.clone(),
187 ExternalRootBinding {
188 module: root,
189 module_from_qualifier: true,
190 },
191 );
192 }
193 }
194 "elixir" => {
195 for (root, module) in &import_context.elixir_external_roots {
196 if import_context.elixir_local_module_roots.contains(root) {
197 continue;
198 }
199 let module = import_context
200 .elixir_external_root_module(root)
201 .unwrap_or(module);
202 bindings.external_roots.insert(
203 root.clone(),
204 ExternalRootBinding {
205 module: module.to_string(),
206 module_from_qualifier: true,
207 },
208 );
209 }
210 for (root, module) in &import_context.elixir_external_root_overrides {
211 if import_context.elixir_external_roots.contains_key(root)
212 || import_context.elixir_local_module_roots.contains(root)
213 {
214 continue;
215 }
216 bindings.external_roots.insert(
217 root.clone(),
218 ExternalRootBinding {
219 module: module.clone(),
220 module_from_qualifier: true,
221 },
222 );
223 }
224 }
225 _ => {}
226 }
227}
228
229pub(crate) fn resolve_external_callee(
230 import_context: &ImportResolutionContext,
231 import_bindings: &ImportBindings,
232 symbols: &[Symbol],
233 callee_name: &str,
234 root_alias: Option<&str>,
235 qualifier_path: Option<&str>,
236 is_bare_call: bool,
237) -> Option<ExternalCallTarget> {
238 if is_bare_call {
239 if symbols.iter().any(|symbol| symbol.name == callee_name) {
240 return None;
241 }
242 if let Some(binding) = import_bindings.bare.get(callee_name) {
243 return Some(ExternalCallTarget {
244 module: binding.module.clone(),
245 callee_name: binding.callee_name.clone(),
246 });
247 }
248 if import_bindings.bare_wildcard_modules.len() == 1 {
249 return Some(ExternalCallTarget {
250 module: import_bindings.bare_wildcard_modules[0].clone(),
251 callee_name: callee_name.to_string(),
252 });
253 }
254 return None;
256 }
257
258 let root_alias = root_alias?;
259 if symbols.iter().any(|symbol| symbol.name == root_alias) {
260 return None;
261 }
262 if let Some(module) = import_bindings.member.get(root_alias) {
263 return Some(ExternalCallTarget {
264 module: module.clone(),
265 callee_name: callee_name.to_string(),
266 });
267 }
268
269 let qualifier_path = qualifier_path?;
270 if qualifier_path.starts_with('\\') {
271 let module = qualifier_path.trim_start_matches('\\');
272 if module.is_empty() {
273 return None;
274 }
275 let local_symbol = format!("{module}\\{callee_name}");
276 if import_context.php_local_symbols.contains(module)
277 || import_context.php_local_symbols.contains(&local_symbol)
278 {
279 return None;
280 }
281 return Some(ExternalCallTarget {
282 module: module.to_string(),
283 callee_name: callee_name.to_string(),
284 });
285 }
286 let root_binding = import_bindings.external_roots.get(root_alias)?;
287 let module = if root_binding.module_from_qualifier {
288 qualifier_path.to_string()
289 } else {
290 root_binding.module.clone()
291 };
292 Some(ExternalCallTarget {
293 module,
294 callee_name: callee_name.to_string(),
295 })
296}
297
298fn build_python_module_index(root_path: &Path, candidate_files: &[PathBuf]) -> HashSet<String> {
299 let mut modules = HashSet::new();
300
301 for path in candidate_files {
302 let Ok(rel) = path.strip_prefix(root_path) else {
303 continue;
304 };
305 let ext = rel
306 .extension()
307 .and_then(|ext| ext.to_str())
308 .unwrap_or_default()
309 .to_ascii_lowercase();
310 if !matches!(ext.as_str(), "py" | "pyi") {
311 continue;
312 }
313
314 let mut module = rel
315 .with_extension("")
316 .to_string_lossy()
317 .replace(['/', '\\'], ".");
318 if module.ends_with(".__init__") {
319 module.truncate(module.len() - ".__init__".len());
320 }
321 if module.is_empty() {
322 continue;
323 }
324 modules.insert(module.clone());
325
326 if let Some(stripped) = module.strip_prefix("src.") {
327 modules.insert(stripped.to_string());
328 }
329 }
330
331 modules
332}
333
334fn load_js_external_packages(root_path: &Path) -> HashSet<String> {
335 let package_json = root_path.join("package.json");
336 let Ok(contents) = std::fs::read_to_string(package_json) else {
337 return HashSet::new();
338 };
339 let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
340 return HashSet::new();
341 };
342
343 let mut packages = HashSet::new();
344 for field in [
345 "dependencies",
346 "devDependencies",
347 "peerDependencies",
348 "optionalDependencies",
349 "bundledDependencies",
350 ] {
351 if let Some(map) = json.get(field).and_then(|value| value.as_object()) {
352 packages.extend(map.keys().cloned());
353 }
354 }
355 packages
356}
357
358fn load_js_self_package_name(root_path: &Path) -> Option<String> {
359 let package_json = root_path.join("package.json");
360 let contents = std::fs::read_to_string(package_json).ok()?;
361 let json = serde_json::from_str::<serde_json::Value>(&contents).ok()?;
362 json.get("name")
363 .and_then(|value| value.as_str())
364 .map(ToOwned::to_owned)
365}
366
367fn load_go_module_path(root_path: &Path) -> Option<String> {
368 let contents = std::fs::read_to_string(root_path.join("go.mod")).ok()?;
369 contents.lines().find_map(|line| {
370 let line = line.trim();
371 line.strip_prefix("module ")
372 .map(str::trim)
373 .filter(|module| !module.is_empty())
374 .map(ToOwned::to_owned)
375 })
376}
377
378fn load_rust_external_crates(root_path: &Path) -> HashSet<String> {
379 let Ok(contents) = std::fs::read_to_string(root_path.join("Cargo.toml")) else {
380 return HashSet::new();
381 };
382 let Ok(cargo_toml) = toml::from_str::<toml::Table>(&contents) else {
383 return HashSet::new();
384 };
385 let mut crates = HashSet::new();
386
387 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
388 collect_rust_dependency_keys(cargo_toml.get(section), &mut crates);
389 }
390
391 if let Some(targets) = cargo_toml.get("target").and_then(toml::Value::as_table) {
392 for target in targets.values() {
393 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
394 collect_rust_dependency_keys(target.get(section), &mut crates);
395 }
396 }
397 }
398
399 crates
400}
401
402fn load_rust_self_crate_name(root_path: &Path) -> Option<String> {
403 let contents = std::fs::read_to_string(root_path.join("Cargo.toml")).ok()?;
404 let cargo_toml = toml::from_str::<toml::Table>(&contents).ok()?;
405 cargo_toml
406 .get("package")
407 .and_then(|package| package.get("name"))
408 .and_then(toml::Value::as_str)
409 .map(normalize_rust_crate_name)
410 .filter(|name| !name.is_empty())
411}
412
413fn collect_rust_dependency_keys(value: Option<&toml::Value>, crates: &mut HashSet<String>) {
414 let Some(table) = value.and_then(toml::Value::as_table) else {
415 return;
416 };
417 for name in table.keys() {
418 let name = normalize_rust_crate_name(name);
419 if !name.is_empty() {
420 crates.insert(name);
421 }
422 }
423}
424
425fn normalize_rust_crate_name(name: &str) -> String {
426 name.trim().replace('-', "_")
427}
428
429fn build_java_local_class_index(candidate_files: &[PathBuf]) -> HashSet<String> {
430 let mut classes = HashSet::new();
431 for path in candidate_files {
432 let ext = path
433 .extension()
434 .and_then(|ext| ext.to_str())
435 .unwrap_or_default();
436 if ext != "java" {
437 continue;
438 }
439 let Ok(contents) = std::fs::read_to_string(path) else {
440 continue;
441 };
442 let package = contents.lines().find_map(|line| {
443 let line = line.trim();
444 line.strip_prefix("package ")
445 .map(|rest| rest.trim().trim_end_matches(';').trim().to_string())
446 });
447 for class_name in java_declared_types(&contents) {
448 classes.insert(class_name.clone());
449 if let Some(package) = package.as_deref()
450 && !package.is_empty()
451 {
452 classes.insert(format!("{package}.{class_name}"));
453 }
454 }
455 }
456 classes
457}
458
459fn build_csharp_local_roots(candidate_files: &[PathBuf]) -> HashSet<String> {
460 let mut roots = HashSet::new();
461 for path in candidate_files {
462 let ext = path
463 .extension()
464 .and_then(|ext| ext.to_str())
465 .unwrap_or_default();
466 if ext != "cs" {
467 continue;
468 }
469 let Ok(contents) = std::fs::read_to_string(path) else {
470 continue;
471 };
472 for line in contents.lines() {
473 let line = line.trim();
474 if let Some(rest) = line.strip_prefix("namespace ") {
475 let namespace = rest
476 .trim()
477 .trim_end_matches([';', '{'])
478 .split_whitespace()
479 .next()
480 .unwrap_or_default();
481 if let Some(root) = namespace.split('.').next()
482 && !root.is_empty()
483 {
484 roots.insert(root.to_string());
485 }
486 }
487 }
488 for type_name in csharp_declared_types(&contents) {
489 roots.insert(type_name);
490 }
491 }
492 roots
493}
494
495fn build_php_local_symbol_index(candidate_files: &[PathBuf]) -> HashSet<String> {
496 let mut symbols = HashSet::new();
497 for path in candidate_files {
498 let ext = path
499 .extension()
500 .and_then(|ext| ext.to_str())
501 .unwrap_or_default();
502 if ext != "php" {
503 continue;
504 }
505 let Ok(contents) = std::fs::read_to_string(path) else {
506 continue;
507 };
508 let namespace = contents.lines().find_map(|line| {
509 let line = line.trim();
510 line.strip_prefix("namespace ")
511 .map(|rest| rest.trim().trim_end_matches([';', '{']).to_string())
512 });
513 for name in php_declared_symbols(&contents) {
514 symbols.insert(name.clone());
515 if let Some(namespace) = namespace.as_deref()
516 && !namespace.is_empty()
517 {
518 symbols.insert(format!("{namespace}\\{name}"));
519 }
520 }
521 }
522 symbols
523}
524
525fn build_ruby_local_constant_roots(candidate_files: &[PathBuf]) -> HashSet<String> {
526 let mut roots = HashSet::new();
527 for path in candidate_files {
528 let ext = path
529 .extension()
530 .and_then(|ext| ext.to_str())
531 .unwrap_or_default();
532 if !matches!(ext, "rb" | "rake" | "gemspec") {
533 continue;
534 }
535 let Ok(contents) = std::fs::read_to_string(path) else {
536 continue;
537 };
538 for line in contents.lines() {
539 let line = line.trim_start();
540 let Some(rest) = line
541 .strip_prefix("class ")
542 .or_else(|| line.strip_prefix("module "))
543 else {
544 continue;
545 };
546 let name = rest
547 .split(|ch: char| ch.is_whitespace() || matches!(ch, '<' | '(' | ';' | '#'))
548 .next()
549 .unwrap_or_default()
550 .trim_start_matches("::");
551 if let Some(root) = name.split("::").next()
552 && is_ruby_constant_name(root)
553 {
554 roots.insert(root.to_string());
555 }
556 }
557 }
558 roots
559}
560
561fn load_dart_external_packages(root_path: &Path) -> HashSet<String> {
562 let contents = std::fs::read_to_string(root_path.join("pubspec.yaml")).unwrap_or_default();
563 let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents) else {
564 return HashSet::new();
565 };
566
567 let mut packages = HashSet::new();
568 for field in ["dependencies", "dev_dependencies", "dependency_overrides"] {
569 if let Some(map) = yaml.get(field).and_then(|value| value.as_mapping()) {
570 for key in map.keys().filter_map(|key| key.as_str()) {
571 if !key.is_empty() && key != "sdk" {
572 packages.insert(key.to_string());
573 }
574 }
575 }
576 }
577 packages
578}
579
580fn load_dart_self_package_name(root_path: &Path) -> Option<String> {
581 let contents = std::fs::read_to_string(root_path.join("pubspec.yaml")).ok()?;
582 let yaml = serde_yaml::from_str::<serde_yaml::Value>(&contents).ok()?;
583 yaml.get("name")
584 .and_then(|value| value.as_str())
585 .map(ToOwned::to_owned)
586}
587
588fn build_elixir_local_module_roots(candidate_files: &[PathBuf]) -> HashSet<String> {
589 let mut roots = HashSet::new();
590 for path in candidate_files {
591 let ext = path
592 .extension()
593 .and_then(|ext| ext.to_str())
594 .unwrap_or_default();
595 if !matches!(ext, "ex" | "exs") {
596 continue;
597 }
598 let Ok(contents) = std::fs::read_to_string(path) else {
599 continue;
600 };
601 for line in contents.lines() {
602 let line = line.trim_start();
603 let Some(rest) = line.strip_prefix("defmodule ") else {
604 continue;
605 };
606 let module = rest
607 .split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | '(' | '['))
608 .next()
609 .unwrap_or_default();
610 if let Some(root) = module.split('.').next()
611 && is_elixir_alias(root)
612 {
613 roots.insert(root.to_string());
614 }
615 }
616 }
617 roots
618}
619
620fn load_elixir_external_roots(root_path: &Path) -> HashMap<String, String> {
621 let deps = load_elixir_dependency_names(root_path);
622 let mut roots = HashMap::new();
623 for dep in deps {
624 if let Some(dep_roots) = elixir_dependency_roots(&dep) {
625 for root in dep_roots {
626 roots.insert(root.clone(), root.clone());
627 }
628 }
629 }
630 roots
631}
632
633fn load_elixir_dependency_names(root_path: &Path) -> HashSet<String> {
634 let mut deps = HashSet::new();
635 if let Ok(contents) = std::fs::read_to_string(root_path.join("mix.exs")) {
636 for line in contents.lines() {
637 let trimmed = line.trim();
638 if let Some(rest) = trimmed.strip_prefix("{:") {
639 let dep = rest
640 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
641 .next()
642 .unwrap_or_default();
643 if !dep.is_empty() {
644 deps.insert(dep.to_string());
645 }
646 }
647 }
648 }
649 if let Ok(contents) = std::fs::read_to_string(root_path.join("mix.lock")) {
650 for line in contents
651 .lines()
652 .map(str::trim)
653 .filter(|line| !line.is_empty())
654 {
655 let Some(start) = line.find('"') else {
656 continue;
657 };
658 let rest = &line[start + 1..];
659 let Some(end) = rest.find('"') else {
660 continue;
661 };
662 let dep = &rest[..end];
663 if dep
664 .chars()
665 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
666 {
667 deps.insert(dep.to_string());
668 }
669 }
670 }
671 deps
672}
673
674fn parse_python_import_statement(
675 text: &str,
676 rel_path: &str,
677 import_context: &ImportResolutionContext,
678 extracted: &mut ExtractedImports,
679) {
680 if let Some(rest) = text.strip_prefix("import ") {
681 for entry in split_top_level(rest, ',') {
682 let entry = entry.trim();
683 if entry.is_empty() {
684 continue;
685 }
686
687 let (module, alias) = split_alias(entry);
688 extracted.imports.push(ImportRelation {
689 file_path: rel_path.to_string(),
690 module_name: module.to_string(),
691 });
692
693 if is_external_python_module(module, import_context) {
694 let local_alias = alias
695 .map(ToOwned::to_owned)
696 .unwrap_or_else(|| module.split('.').next().unwrap_or(module).to_string());
697 extracted
698 .bindings
699 .member
700 .insert(local_alias, module.to_string());
701 }
702 }
703 return;
704 }
705
706 let Some(rest) = text.strip_prefix("from ") else {
707 extracted.imports.push(ImportRelation {
708 file_path: rel_path.to_string(),
709 module_name: text.to_string(),
710 });
711 return;
712 };
713 let Some((module, imported)) = rest.split_once(" import ") else {
714 extracted.imports.push(ImportRelation {
715 file_path: rel_path.to_string(),
716 module_name: text.to_string(),
717 });
718 return;
719 };
720
721 let module = module.trim();
722 extracted.imports.push(ImportRelation {
723 file_path: rel_path.to_string(),
724 module_name: module.to_string(),
725 });
726
727 if !is_external_python_module(module, import_context) {
728 return;
729 }
730
731 let imported = imported.trim().trim_matches(|ch| matches!(ch, '(' | ')'));
732 for entry in split_top_level(imported, ',') {
733 let entry = entry.trim();
734 if entry.is_empty() || entry == "*" {
735 continue;
736 }
737 let (imported_name, alias) = split_alias(entry);
738 let local_alias = alias.unwrap_or(imported_name).to_string();
739 extracted.bindings.bare.insert(
740 local_alias.clone(),
741 ExternalImportBinding {
742 module: module.to_string(),
743 callee_name: imported_name.to_string(),
744 },
745 );
746 extracted
747 .bindings
748 .member
749 .insert(local_alias, module.to_string());
750 }
751}
752
753fn parse_js_import_statement(
754 text: &str,
755 rel_path: &str,
756 import_context: &ImportResolutionContext,
757 extracted: &mut ExtractedImports,
758) {
759 let normalized = collapse_whitespace(text);
760 let Some(specifier) = extract_js_module_specifier(&normalized) else {
761 extracted.imports.push(ImportRelation {
762 file_path: rel_path.to_string(),
763 module_name: normalized,
764 });
765 return;
766 };
767
768 extracted.imports.push(ImportRelation {
769 file_path: rel_path.to_string(),
770 module_name: specifier.clone(),
771 });
772
773 if !is_external_js_module(&specifier, import_context) {
774 return;
775 }
776
777 let Some(clause) = extract_js_import_clause(&normalized) else {
778 return;
779 };
780 let clause = clause.trim();
781 if clause.is_empty() || (clause.starts_with("type ") && !clause.contains(',')) {
782 return;
783 }
784
785 for part in split_top_level(clause, ',') {
786 let part = part.trim();
787 if part.is_empty() {
788 continue;
789 }
790 if let Some(alias) = part.strip_prefix("* as ") {
791 let alias = alias.trim();
792 if !alias.is_empty() {
793 extracted
794 .bindings
795 .member
796 .insert(alias.to_string(), specifier.clone());
797 }
798 continue;
799 }
800 if part.starts_with('{') && part.ends_with('}') {
801 let inner = &part[1..part.len() - 1];
802 for item in split_top_level(inner, ',') {
803 let item = item.trim();
804 if item.is_empty() || item.starts_with("type ") {
805 continue;
806 }
807 let (imported_name, alias) = split_alias(item);
808 let local_alias = alias.unwrap_or(imported_name).to_string();
809 extracted.bindings.bare.insert(
810 local_alias.clone(),
811 ExternalImportBinding {
812 module: specifier.clone(),
813 callee_name: imported_name.to_string(),
814 },
815 );
816 extracted
817 .bindings
818 .member
819 .insert(local_alias, specifier.clone());
820 }
821 continue;
822 }
823
824 let alias = part.strip_prefix("type ").unwrap_or(part).trim();
825 if alias.is_empty() {
826 continue;
827 }
828 extracted.bindings.bare.insert(
829 alias.to_string(),
830 ExternalImportBinding {
831 module: specifier.clone(),
832 callee_name: "default".to_string(),
833 },
834 );
835 extracted
836 .bindings
837 .member
838 .insert(alias.to_string(), specifier.clone());
839 }
840}
841
842fn parse_go_import_statement(
843 text: &str,
844 rel_path: &str,
845 import_context: &ImportResolutionContext,
846 extracted: &mut ExtractedImports,
847) {
848 let Some(rest) = text.trim().strip_prefix("import") else {
849 extracted.imports.push(ImportRelation {
850 file_path: rel_path.to_string(),
851 module_name: text.to_string(),
852 });
853 return;
854 };
855
856 let rest = rest.trim();
857 if rest.starts_with('(') {
858 let block = rest.trim_start_matches('(').trim_end_matches(')');
859 for line in block.lines() {
860 parse_go_import_spec(line.trim(), rel_path, import_context, extracted);
861 }
862 } else {
863 parse_go_import_spec(rest, rel_path, import_context, extracted);
864 }
865}
866
867fn parse_go_import_spec(
868 text: &str,
869 rel_path: &str,
870 import_context: &ImportResolutionContext,
871 extracted: &mut ExtractedImports,
872) {
873 let text = text.split("//").next().unwrap_or(text).trim();
874 if text.is_empty() {
875 return;
876 }
877 let Some(module) = extract_quoted_string(text) else {
878 return;
879 };
880
881 extracted.imports.push(ImportRelation {
882 file_path: rel_path.to_string(),
883 module_name: module.clone(),
884 });
885
886 if !is_external_go_module(&module, import_context) {
887 return;
888 }
889
890 let alias = text[..text.find(['"', '`']).unwrap_or(0)].trim();
891 if matches!(alias, "_" | ".") {
892 return;
893 }
894 let local_alias = if alias.is_empty() {
895 go_default_package_alias(&module)
896 } else {
897 alias.to_string()
898 };
899 if !local_alias.is_empty() {
900 extracted.bindings.member.insert(local_alias, module);
901 }
902}
903
904fn parse_rust_import_statement(
905 text: &str,
906 rel_path: &str,
907 import_context: &ImportResolutionContext,
908 extracted: &mut ExtractedImports,
909) {
910 let Some(rest) = text.trim().strip_prefix("use ") else {
911 extracted.imports.push(ImportRelation {
912 file_path: rel_path.to_string(),
913 module_name: text.to_string(),
914 });
915 return;
916 };
917 let rest = rest.trim().trim_end_matches(';').trim();
918 extracted.imports.push(ImportRelation {
919 file_path: rel_path.to_string(),
920 module_name: rest.to_string(),
921 });
922
923 if let Some((prefix, group)) = split_rust_use_group(rest) {
924 register_rust_group_imports(prefix, group, import_context, extracted);
925 return;
926 }
927
928 if rest.contains('*') {
929 return;
931 }
932
933 register_rust_path_import(rest, import_context, extracted);
934}
935
936fn register_rust_group_imports(
937 prefix: &str,
938 group: &str,
939 import_context: &ImportResolutionContext,
940 extracted: &mut ExtractedImports,
941) {
942 for item in split_top_level(group, ',') {
943 if item.is_empty() {
944 continue;
945 }
946 if let Some((nested_prefix, nested_group)) = split_rust_use_group(item) {
947 let Some(full_prefix) = rust_join_use_path(prefix, nested_prefix) else {
948 continue;
949 };
950 register_rust_group_imports(&full_prefix, nested_group, import_context, extracted);
951 continue;
952 }
953 if item.contains('*') {
954 continue;
956 }
957 let Some(path) = rust_join_use_path(prefix, item) else {
958 continue;
959 };
960 register_rust_path_import(&path, import_context, extracted);
961 }
962}
963
964fn register_rust_path_import(
965 path_and_alias: &str,
966 import_context: &ImportResolutionContext,
967 extracted: &mut ExtractedImports,
968) {
969 let normalized = path_and_alias.trim();
970 if normalized.is_empty() {
971 return;
972 }
973 let (path, alias) = split_alias(normalized);
974 let segments: Vec<&str> = path.split("::").filter(|part| !part.is_empty()).collect();
975 let Some(root) = segments.first().copied() else {
976 return;
977 };
978 if !is_external_rust_root(root, import_context) {
979 return;
980 }
981
982 extracted.bindings.external_roots.insert(
983 root.to_string(),
984 ExternalRootBinding {
985 module: root.to_string(),
986 module_from_qualifier: true,
987 },
988 );
989
990 let Some(imported_name) = segments.last().copied() else {
991 return;
992 };
993 let local_alias = alias.unwrap_or(imported_name);
994 if local_alias.is_empty() {
995 return;
996 }
997
998 let module = if segments.len() > 1 {
999 segments[..segments.len() - 1].join("::")
1000 } else {
1001 root.to_string()
1002 };
1003 extracted.bindings.bare.insert(
1004 local_alias.to_string(),
1005 ExternalImportBinding {
1006 module: module.clone(),
1007 callee_name: imported_name.to_string(),
1008 },
1009 );
1010 extracted
1011 .bindings
1012 .member
1013 .insert(local_alias.to_string(), path.to_string());
1014}
1015
1016fn parse_java_import_statement(
1017 text: &str,
1018 rel_path: &str,
1019 import_context: &ImportResolutionContext,
1020 extracted: &mut ExtractedImports,
1021) {
1022 let normalized = text.trim().trim_end_matches(';').trim();
1023 let Some(rest) = normalized.strip_prefix("import ") else {
1024 extracted.imports.push(ImportRelation {
1025 file_path: rel_path.to_string(),
1026 module_name: normalized.to_string(),
1027 });
1028 return;
1029 };
1030
1031 let (is_static, target) = rest
1032 .strip_prefix("static ")
1033 .map(|target| (true, target.trim()))
1034 .unwrap_or((false, rest.trim()));
1035 extracted.imports.push(ImportRelation {
1036 file_path: rel_path.to_string(),
1037 module_name: target.to_string(),
1038 });
1039
1040 if target.ends_with(".*") {
1041 return;
1042 }
1043
1044 if is_static {
1045 let Some((class_path, member_name)) = target.rsplit_once('.') else {
1046 return;
1047 };
1048 if !is_external_java_class(class_path, import_context) {
1049 return;
1050 }
1051 extracted.bindings.bare.insert(
1052 member_name.to_string(),
1053 ExternalImportBinding {
1054 module: class_path.to_string(),
1055 callee_name: member_name.to_string(),
1056 },
1057 );
1058 return;
1059 }
1060
1061 if !is_external_java_class(target, import_context) {
1062 return;
1063 }
1064 let class_alias = target.rsplit('.').next().unwrap_or(target);
1065 extracted
1066 .bindings
1067 .member
1068 .insert(class_alias.to_string(), target.to_string());
1069}
1070
1071fn parse_csharp_import_statement(
1072 text: &str,
1073 rel_path: &str,
1074 import_context: &ImportResolutionContext,
1075 extracted: &mut ExtractedImports,
1076) {
1077 let normalized = text.trim().trim_end_matches(';').trim();
1078 let Some(rest) = normalized.strip_prefix("using ") else {
1079 extracted.imports.push(ImportRelation {
1080 file_path: rel_path.to_string(),
1081 module_name: normalized.to_string(),
1082 });
1083 return;
1084 };
1085
1086 if let Some(target) = rest.strip_prefix("static ") {
1087 let target = target.trim();
1088 extracted.imports.push(ImportRelation {
1089 file_path: rel_path.to_string(),
1090 module_name: target.to_string(),
1091 });
1092 if is_external_csharp_path(target, import_context) {
1093 extracted
1094 .bindings
1095 .bare_wildcard_modules
1096 .push(target.to_string());
1097 }
1098 return;
1099 }
1100
1101 if let Some((alias, target)) = rest.split_once('=') {
1102 let alias = alias.trim();
1103 let target = target.trim();
1104 extracted.imports.push(ImportRelation {
1105 file_path: rel_path.to_string(),
1106 module_name: target.to_string(),
1107 });
1108 if !alias.is_empty() && is_external_csharp_path(target, import_context) {
1109 extracted
1110 .bindings
1111 .member
1112 .insert(alias.to_string(), target.to_string());
1113 }
1114 return;
1115 }
1116
1117 let namespace = rest.trim();
1118 extracted.imports.push(ImportRelation {
1119 file_path: rel_path.to_string(),
1120 module_name: namespace.to_string(),
1121 });
1122 if !is_external_csharp_path(namespace, import_context) {
1123 return;
1124 }
1125 if let Some(root) = namespace.split('.').next()
1126 && !root.is_empty()
1127 {
1128 extracted.bindings.external_roots.insert(
1129 root.to_string(),
1130 ExternalRootBinding {
1131 module: root.to_string(),
1132 module_from_qualifier: true,
1133 },
1134 );
1135 }
1136}
1137
1138fn parse_php_import_statement(
1139 text: &str,
1140 rel_path: &str,
1141 import_context: &ImportResolutionContext,
1142 extracted: &mut ExtractedImports,
1143) {
1144 let normalized = text.trim().trim_end_matches(';').trim();
1145 let Some(rest) = normalized.strip_prefix("use ") else {
1146 extracted.imports.push(ImportRelation {
1147 file_path: rel_path.to_string(),
1148 module_name: normalized.to_string(),
1149 });
1150 return;
1151 };
1152 let (kind, rest) = if let Some(target) = rest.strip_prefix("function ") {
1153 (PhpImportKind::Function, target.trim())
1154 } else if let Some(target) = rest.strip_prefix("const ") {
1155 (PhpImportKind::Const, target.trim())
1156 } else {
1157 (PhpImportKind::ClassLike, rest.trim())
1158 };
1159
1160 if rest.contains('*') {
1161 extracted.imports.push(ImportRelation {
1162 file_path: rel_path.to_string(),
1163 module_name: rest.to_string(),
1164 });
1165 return;
1166 }
1167
1168 if let Some((base, group)) = split_php_use_group(rest) {
1169 for item in split_top_level(group, ',') {
1170 if let Some(target) = php_join_use_path(base, item) {
1171 register_php_import_item(&target, kind, rel_path, import_context, extracted);
1172 }
1173 }
1174 return;
1175 }
1176
1177 if rest.contains('{') || rest.contains('}') {
1178 extracted.imports.push(ImportRelation {
1179 file_path: rel_path.to_string(),
1180 module_name: rest.to_string(),
1181 });
1182 return;
1183 }
1184
1185 for item in split_top_level(rest, ',') {
1186 register_php_import_item(item, kind, rel_path, import_context, extracted);
1187 }
1188}
1189
1190#[derive(Clone, Copy)]
1191enum PhpImportKind {
1192 ClassLike,
1193 Function,
1194 Const,
1195}
1196
1197fn register_php_import_item(
1198 item: &str,
1199 kind: PhpImportKind,
1200 rel_path: &str,
1201 import_context: &ImportResolutionContext,
1202 extracted: &mut ExtractedImports,
1203) {
1204 let item = item.trim();
1205 if item.is_empty() {
1206 return;
1207 }
1208 let (target, alias) = split_alias(item);
1209 let target = target.trim_start_matches('\\');
1210 extracted.imports.push(ImportRelation {
1211 file_path: rel_path.to_string(),
1212 module_name: target.to_string(),
1213 });
1214 if !is_external_php_symbol(target, import_context) {
1215 return;
1216 }
1217
1218 let imported_name = target.rsplit('\\').next().unwrap_or(target);
1219 let local_alias = alias.unwrap_or(imported_name);
1220 if matches!(kind, PhpImportKind::Function) {
1221 let module = target
1222 .rsplit_once('\\')
1223 .map(|(module, _)| module)
1224 .unwrap_or(target);
1225 extracted.bindings.bare.insert(
1226 local_alias.to_string(),
1227 ExternalImportBinding {
1228 module: module.to_string(),
1229 callee_name: imported_name.to_string(),
1230 },
1231 );
1232 } else {
1233 extracted
1234 .bindings
1235 .member
1236 .insert(local_alias.to_string(), target.to_string());
1237 }
1238}
1239
1240fn split_php_use_group(text: &str) -> Option<(&str, &str)> {
1241 let (base, group) = split_rust_use_group(text)?;
1242 if base.is_empty() || group.is_empty() {
1243 return None;
1244 }
1245 Some((base, group))
1246}
1247
1248fn php_join_use_path(prefix: &str, item: &str) -> Option<String> {
1249 let prefix = prefix.trim().trim_start_matches('\\');
1250 let (item_path, alias) = split_alias(item);
1251 let item_path = item_path.trim().trim_start_matches('\\');
1252 if item_path.is_empty() {
1253 return None;
1254 }
1255
1256 let path = if prefix.is_empty() {
1257 item_path.to_string()
1258 } else if prefix.ends_with('\\') {
1259 format!("{prefix}{item_path}")
1260 } else {
1261 format!("{prefix}\\{item_path}")
1262 };
1263
1264 Some(match alias {
1265 Some(alias) if !alias.is_empty() => format!("{path} as {alias}"),
1266 _ => path,
1267 })
1268}
1269
1270fn parse_swift_import_statement(text: &str, rel_path: &str, extracted: &mut ExtractedImports) {
1271 let normalized = text.trim();
1272 let Some(rest) = normalized.strip_prefix("import ") else {
1273 extracted.imports.push(ImportRelation {
1274 file_path: rel_path.to_string(),
1275 module_name: normalized.to_string(),
1276 });
1277 return;
1278 };
1279
1280 let mut tokens = rest.split_whitespace();
1281 let mut module_token = tokens.next().unwrap_or_default();
1282 if matches!(
1283 module_token,
1284 "class" | "struct" | "enum" | "protocol" | "func" | "typealias" | "var" | "let"
1285 ) {
1286 module_token = tokens.next().unwrap_or_default();
1287 }
1288 let module = module_token.split('.').next().unwrap_or_default();
1289 extracted.imports.push(ImportRelation {
1290 file_path: rel_path.to_string(),
1291 module_name: rest.to_string(),
1292 });
1293 if module.is_empty()
1294 || matches!(
1295 module,
1296 "class" | "struct" | "enum" | "protocol" | "func" | "typealias" | "var" | "let"
1297 )
1298 {
1299 return;
1300 }
1301
1302 extracted.bindings.external_roots.insert(
1303 module.to_string(),
1304 ExternalRootBinding {
1305 module: module.to_string(),
1306 module_from_qualifier: false,
1307 },
1308 );
1309}
1310
1311fn parse_ruby_import_statement(
1312 text: &str,
1313 rel_path: &str,
1314 import_context: &ImportResolutionContext,
1315 extracted: &mut ExtractedImports,
1316) {
1317 let normalized = text.trim();
1318 let Some(method) = normalized.split_whitespace().next() else {
1319 return;
1320 };
1321
1322 let literal = extract_quoted_string(normalized);
1323 extracted.imports.push(ImportRelation {
1324 file_path: rel_path.to_string(),
1325 module_name: literal.clone().unwrap_or_else(|| normalized.to_string()),
1326 });
1327
1328 if method != "require" {
1329 return;
1330 }
1331 let Some(required) = literal else {
1332 return;
1333 };
1334 let Some(root) = import_context.ruby_require_root(&required) else {
1335 return;
1336 };
1337 if import_context.ruby_local_constant_roots.contains(root) {
1338 return;
1339 }
1340 extracted.bindings.external_roots.insert(
1341 root.to_string(),
1342 ExternalRootBinding {
1343 module: required,
1344 module_from_qualifier: false,
1345 },
1346 );
1347}
1348
1349fn parse_dart_import_statement(
1350 text: &str,
1351 rel_path: &str,
1352 import_context: &ImportResolutionContext,
1353 extracted: &mut ExtractedImports,
1354) {
1355 let normalized = collapse_whitespace(text);
1356 let Some(uri) = extract_quoted_string(&normalized) else {
1357 extracted.imports.push(ImportRelation {
1358 file_path: rel_path.to_string(),
1359 module_name: normalized,
1360 });
1361 return;
1362 };
1363
1364 extracted.imports.push(ImportRelation {
1365 file_path: rel_path.to_string(),
1366 module_name: uri.clone(),
1367 });
1368
1369 if !normalized.starts_with("import ") || !is_external_dart_uri(&uri, import_context) {
1370 return;
1371 }
1372 let Some(alias) = dart_import_alias(&normalized) else {
1373 return;
1374 };
1375 extracted.bindings.member.insert(alias, uri);
1376}
1377
1378fn parse_elixir_import_statement(
1379 text: &str,
1380 rel_path: &str,
1381 import_context: &ImportResolutionContext,
1382 extracted: &mut ExtractedImports,
1383) {
1384 let normalized = collapse_whitespace(text);
1385 let Some((keyword, rest)) = normalized.split_once(' ') else {
1386 extracted.imports.push(ImportRelation {
1387 file_path: rel_path.to_string(),
1388 module_name: normalized,
1389 });
1390 return;
1391 };
1392 let target = rest.split([',', ' ']).next().unwrap_or_default().trim();
1393 extracted.imports.push(ImportRelation {
1394 file_path: rel_path.to_string(),
1395 module_name: if target.is_empty() {
1396 normalized.clone()
1397 } else {
1398 target.to_string()
1399 },
1400 });
1401
1402 if !matches!(keyword, "alias" | "require") || !is_elixir_alias_path(target) {
1403 return;
1404 }
1405 let Some(root) = target.split('.').next() else {
1406 return;
1407 };
1408 if import_context.elixir_local_module_roots.contains(root) {
1409 return;
1410 }
1411 let Some(module) = import_context.elixir_external_root_module(root) else {
1412 return;
1413 };
1414
1415 if keyword == "alias" {
1416 let alias = elixir_alias_as(&normalized)
1417 .unwrap_or_else(|| target.rsplit('.').next().unwrap_or(target).to_string());
1418 extracted.bindings.member.insert(alias, target.to_string());
1419 }
1420 extracted.bindings.external_roots.insert(
1421 root.to_string(),
1422 ExternalRootBinding {
1423 module: module.to_string(),
1424 module_from_qualifier: true,
1425 },
1426 );
1427}
1428
1429fn collapse_whitespace(text: &str) -> String {
1430 text.split_whitespace().collect::<Vec<_>>().join(" ")
1431}
1432
1433fn extract_js_module_specifier(text: &str) -> Option<String> {
1434 if let Some((_, after_from)) = text.rsplit_once(" from ") {
1435 return extract_quoted_string(after_from);
1436 }
1437 let rest = text.strip_prefix("import ")?;
1438 extract_quoted_string(rest)
1439}
1440
1441fn extract_js_import_clause(text: &str) -> Option<&str> {
1442 let rest = text.strip_prefix("import ")?;
1443 let (clause, _) = rest.rsplit_once(" from ")?;
1444 Some(clause)
1445}
1446
1447fn extract_quoted_string(text: &str) -> Option<String> {
1448 let quote = text.find(['"', '\'', '`'])?;
1449 let quote_char = text[quote..].chars().next()?;
1450 let after_quote = &text[quote + quote_char.len_utf8()..];
1451 let end = after_quote.find(quote_char)?;
1452 Some(after_quote[..end].to_string())
1453}
1454
1455fn go_default_package_alias(module: &str) -> String {
1456 let module = module.trim_end_matches('/');
1457 let last_segment = module.rsplit('/').next().unwrap_or(module);
1458 last_segment
1459 .split_once(".v")
1460 .map(|(name, _)| name)
1461 .unwrap_or(last_segment)
1462 .replace('-', "_")
1463}
1464
1465fn split_alias(text: &str) -> (&str, Option<&str>) {
1466 if let Some((name, alias)) = text.split_once(" as ") {
1467 (name.trim(), Some(alias.trim()))
1468 } else {
1469 (text.trim(), None)
1470 }
1471}
1472
1473fn split_rust_use_group(text: &str) -> Option<(&str, &str)> {
1474 let mut depth = 0usize;
1475 let mut start = None;
1476
1477 for (idx, ch) in text.char_indices() {
1478 match ch {
1479 '{' => {
1480 if depth == 0 {
1481 start = Some(idx);
1482 }
1483 depth += 1;
1484 }
1485 '}' if depth > 0 => {
1486 depth -= 1;
1487 if depth == 0 {
1488 let start = start?;
1489 if text[idx + ch.len_utf8()..].trim().is_empty() {
1490 return Some((text[..start].trim(), text[start + 1..idx].trim()));
1491 }
1492 return None;
1493 }
1494 }
1495 _ => {}
1496 }
1497 }
1498
1499 None
1500}
1501
1502fn rust_join_use_path(prefix: &str, item: &str) -> Option<String> {
1503 let prefix = prefix.trim().trim_end_matches("::").trim();
1504 let item = item.trim();
1505 if item.is_empty() {
1506 return None;
1507 }
1508
1509 let (item_path, alias) = split_alias(item);
1510 let item_path = item_path.trim();
1511 if item_path.is_empty() {
1512 return None;
1513 }
1514
1515 let path = if item_path == "self" {
1516 if prefix.is_empty() {
1517 return None;
1518 }
1519 prefix.to_string()
1520 } else if prefix.is_empty() {
1521 item_path.to_string()
1522 } else {
1523 format!("{prefix}::{item_path}")
1524 };
1525
1526 Some(match alias {
1527 Some(alias) if !alias.is_empty() => format!("{path} as {alias}"),
1528 _ => path,
1529 })
1530}
1531
1532fn split_top_level(text: &str, delimiter: char) -> Vec<&str> {
1533 let mut parts = Vec::new();
1534 let mut start = 0;
1535 let mut paren_depth = 0usize;
1536 let mut brace_depth = 0usize;
1537 let mut bracket_depth = 0usize;
1538 let mut in_single = false;
1539 let mut in_double = false;
1540
1541 for (idx, ch) in text.char_indices() {
1542 match ch {
1543 '\'' if !in_double => in_single = !in_single,
1544 '"' if !in_single => in_double = !in_double,
1545 '(' if !in_single && !in_double => paren_depth += 1,
1546 ')' if !in_single && !in_double && paren_depth > 0 => paren_depth -= 1,
1547 '{' if !in_single && !in_double => brace_depth += 1,
1548 '}' if !in_single && !in_double && brace_depth > 0 => brace_depth -= 1,
1549 '[' if !in_single && !in_double => bracket_depth += 1,
1550 ']' if !in_single && !in_double && bracket_depth > 0 => bracket_depth -= 1,
1551 ch if ch == delimiter
1552 && !in_single
1553 && !in_double
1554 && paren_depth == 0
1555 && brace_depth == 0
1556 && bracket_depth == 0 =>
1557 {
1558 parts.push(text[start..idx].trim());
1559 start = idx + ch.len_utf8();
1560 }
1561 _ => {}
1562 }
1563 }
1564
1565 if start <= text.len() {
1566 parts.push(text[start..].trim());
1567 }
1568
1569 parts
1570}
1571
1572fn is_external_python_module(module: &str, import_context: &ImportResolutionContext) -> bool {
1573 if module.starts_with('.') {
1574 return false;
1575 }
1576
1577 !import_context.python_modules.iter().any(|local_module| {
1578 local_module == module
1579 || local_module.starts_with(&format!("{module}."))
1580 || module.starts_with(&format!("{local_module}."))
1581 })
1582}
1583
1584fn is_external_js_module(module: &str, import_context: &ImportResolutionContext) -> bool {
1585 if module.starts_with("node:") {
1586 return true;
1587 }
1588 if module.starts_with("./")
1589 || module.starts_with("../")
1590 || module.starts_with('/')
1591 || module.starts_with('#')
1592 || module.starts_with("~/")
1593 || module.starts_with("@/")
1594 {
1595 return false;
1596 }
1597
1598 let Some(package_name) = js_package_name(module) else {
1599 return false;
1600 };
1601 if import_context.js_self_package_name.as_deref() == Some(package_name) {
1602 return false;
1603 }
1604
1605 import_context.js_external_packages.contains(package_name)
1606 || JS_BUILTIN_MODULES.contains(&package_name)
1607}
1608
1609fn is_external_go_module(module: &str, import_context: &ImportResolutionContext) -> bool {
1610 if module.starts_with('.') {
1611 return false;
1612 }
1613 if let Some(self_module) = import_context.go_module_path.as_deref()
1614 && (module == self_module || module.starts_with(&format!("{self_module}/")))
1615 {
1616 return false;
1617 }
1618 true
1619}
1620
1621fn rust_external_roots(import_context: &ImportResolutionContext) -> HashSet<String> {
1622 let mut roots = import_context.rust_external_crates.clone();
1623 roots.extend(
1624 ["std", "core", "alloc", "proc_macro", "test"]
1625 .into_iter()
1626 .map(ToOwned::to_owned),
1627 );
1628 if let Some(self_crate) = import_context.rust_self_crate_name.as_deref() {
1629 roots.remove(self_crate);
1630 }
1631 roots
1632}
1633
1634fn java_declared_types(contents: &str) -> Vec<String> {
1635 declared_types(contents, &["class", "interface", "enum", "record"])
1636}
1637
1638fn csharp_declared_types(contents: &str) -> Vec<String> {
1639 declared_types(
1640 contents,
1641 &["class", "interface", "enum", "record", "struct"],
1642 )
1643}
1644
1645fn declared_types(contents: &str, keywords: &[&str]) -> Vec<String> {
1646 let mut names = Vec::new();
1647 let tokens: Vec<&str> = contents
1648 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
1649 .filter(|token| !token.is_empty())
1650 .collect();
1651 for window in tokens.windows(2) {
1652 if keywords.contains(&window[0]) {
1653 names.push(window[1].to_string());
1654 }
1655 }
1656 names
1657}
1658
1659fn php_declared_symbols(contents: &str) -> Vec<String> {
1660 let mut names = Vec::new();
1661 let tokens: Vec<&str> = contents
1662 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
1663 .filter(|token| !token.is_empty())
1664 .collect();
1665 for window in tokens.windows(2) {
1666 if matches!(
1667 window[0],
1668 "class" | "interface" | "trait" | "enum" | "function"
1669 ) {
1670 names.push(window[1].to_string());
1671 }
1672 }
1673 names
1674}
1675
1676fn is_external_java_class(class_path: &str, import_context: &ImportResolutionContext) -> bool {
1677 !import_context.java_local_classes.contains(class_path)
1678 && class_path
1679 .rsplit('.')
1680 .next()
1681 .is_none_or(|class_name| !import_context.java_local_classes.contains(class_name))
1682}
1683
1684fn is_external_csharp_path(path: &str, import_context: &ImportResolutionContext) -> bool {
1685 path.split('.')
1686 .next()
1687 .is_some_and(|root| !import_context.csharp_local_roots.contains(root))
1688}
1689
1690fn is_external_php_symbol(path: &str, import_context: &ImportResolutionContext) -> bool {
1691 !import_context.php_local_symbols.contains(path)
1692 && path
1693 .rsplit('\\')
1694 .next()
1695 .is_none_or(|name| !import_context.php_local_symbols.contains(name))
1696}
1697
1698fn is_external_rust_root(root: &str, import_context: &ImportResolutionContext) -> bool {
1699 if matches!(root, "crate" | "self" | "super") {
1700 return false;
1701 }
1702 if import_context.rust_self_crate_name.as_deref() == Some(root) {
1703 return false;
1704 }
1705 import_context.rust_external_crates.contains(root)
1706 || matches!(root, "std" | "core" | "alloc" | "proc_macro" | "test")
1707}
1708
1709fn ruby_require_root(required: &str) -> Option<&'static str> {
1715 bundled_ruby_require_roots()
1716 .get(required)
1717 .map(String::as_str)
1718}
1719
1720fn is_ruby_constant_name(name: &str) -> bool {
1721 name.chars()
1722 .next()
1723 .is_some_and(|ch| ch.is_ascii_uppercase())
1724 && name
1725 .chars()
1726 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1727}
1728
1729fn dart_import_alias(text: &str) -> Option<String> {
1730 let after_as = text.split_once(" as ")?.1;
1731 let alias = after_as
1732 .split_whitespace()
1733 .next()
1734 .unwrap_or_default()
1735 .trim_end_matches(';');
1736 if alias.is_empty() {
1737 None
1738 } else {
1739 Some(alias.to_string())
1740 }
1741}
1742
1743fn is_external_dart_uri(uri: &str, import_context: &ImportResolutionContext) -> bool {
1744 if uri.starts_with("dart:") {
1745 return true;
1746 }
1747 let Some(package) = uri
1748 .strip_prefix("package:")
1749 .and_then(|rest| rest.split('/').next())
1750 else {
1751 return false;
1752 };
1753 import_context.dart_self_package_name.as_deref() != Some(package)
1754 && import_context.dart_external_packages.contains(package)
1755}
1756
1757fn elixir_dependency_roots(dep: &str) -> Option<&'static [String]> {
1764 bundled_elixir_dependency_roots()
1765 .get(dep)
1766 .map(Vec::as_slice)
1767}
1768
1769fn bundled_ruby_require_roots() -> &'static HashMap<String, String> {
1770 static ROOTS: OnceLock<HashMap<String, String>> = OnceLock::new();
1771 ROOTS.get_or_init(|| {
1772 serde_json::from_str(include_str!(
1773 "../../assets/import_roots/ruby_require_roots.json"
1774 ))
1775 .expect("bundled Ruby require roots JSON parses")
1776 })
1777}
1778
1779fn bundled_elixir_dependency_roots() -> &'static HashMap<String, Vec<String>> {
1780 static ROOTS: OnceLock<HashMap<String, Vec<String>>> = OnceLock::new();
1781 ROOTS.get_or_init(|| {
1782 serde_json::from_str(include_str!(
1783 "../../assets/import_roots/elixir_dependency_roots.json"
1784 ))
1785 .expect("bundled Elixir dependency roots JSON parses")
1786 })
1787}
1788
1789fn is_elixir_alias(name: &str) -> bool {
1790 name.chars()
1791 .next()
1792 .is_some_and(|ch| ch.is_ascii_uppercase())
1793 && name
1794 .chars()
1795 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1796}
1797
1798fn is_elixir_alias_path(path: &str) -> bool {
1799 path.split('.').all(is_elixir_alias)
1800}
1801
1802fn elixir_alias_as(text: &str) -> Option<String> {
1803 let after = text.split_once(" as: ")?.1;
1804 let alias = after
1805 .split([',', ' ', ')', ']'])
1806 .next()
1807 .unwrap_or_default()
1808 .trim();
1809 if is_elixir_alias(alias) {
1810 Some(alias.to_string())
1811 } else {
1812 None
1813 }
1814}
1815
1816fn js_package_name(module: &str) -> Option<&str> {
1817 if let Some(stripped) = module.strip_prefix('@') {
1818 let mut segments = stripped.split('/');
1819 let scope = segments.next()?;
1820 let package = segments.next()?;
1821 let consumed = scope.len() + package.len() + 2;
1822 module.get(..consumed)
1823 } else {
1824 module.split('/').next()
1825 }
1826}
1827
1828#[cfg(test)]
1829mod tests {
1830 use std::fs;
1831
1832 use tempfile::TempDir;
1833
1834 use super::*;
1835
1836 #[test]
1837 fn loads_rust_inline_table_dependency_names() {
1838 let tempdir = TempDir::new().expect("tempdir");
1839 fs::write(
1840 tempdir.path().join("Cargo.toml"),
1841 r#"
1842[package]
1843name = "app"
1844
1845[dependencies]
1846serde = { version = "1.0" }
1847"tokio-util" = { version = "0.7", features = ["codec"] }
1848"#,
1849 )
1850 .expect("cargo toml");
1851
1852 let crates = load_rust_external_crates(tempdir.path());
1853
1854 assert!(crates.contains("serde"));
1855 assert!(crates.contains("tokio_util"));
1856 }
1857
1858 #[test]
1859 fn loads_rust_dependency_names_from_real_toml_tables() {
1860 let tempdir = TempDir::new().expect("tempdir");
1861 fs::write(
1862 tempdir.path().join("Cargo.toml"),
1863 r#"
1864[package]
1865name = "app"
1866
1867[dependencies]
1868serde = "1" # keep comment parsing delegated to TOML
1869
1870[dev-dependencies]
1871pretty-assertions = "1"
1872
1873[build-dependencies]
1874bindgen = "0.69"
1875
1876[target.'cfg(unix)'.dependencies]
1877nix = "0.27"
1878
1879[target.x86_64-pc-windows-msvc.dev-dependencies]
1880windows-sys = "0.52"
1881
1882[target.'cfg(target_os = "linux")'.build-dependencies]
1883cc = "1"
1884"#,
1885 )
1886 .expect("cargo toml");
1887
1888 let crates = load_rust_external_crates(tempdir.path());
1889
1890 for name in [
1891 "serde",
1892 "pretty_assertions",
1893 "bindgen",
1894 "nix",
1895 "windows_sys",
1896 "cc",
1897 ] {
1898 assert!(crates.contains(name), "missing {name}");
1899 }
1900 }
1901
1902 #[test]
1903 fn ignores_rust_non_dependency_toml_tables() {
1904 let tempdir = TempDir::new().expect("tempdir");
1905 fs::write(
1906 tempdir.path().join("Cargo.toml"),
1907 r#"
1908[package]
1909name = "app"
1910
1911[workspace.dependencies]
1912workspace-only = "1"
1913
1914[package.metadata.dependencies]
1915metadata-only = "1"
1916
1917[features]
1918serde = []
1919"#,
1920 )
1921 .expect("cargo toml");
1922
1923 let crates = load_rust_external_crates(tempdir.path());
1924
1925 assert!(!crates.contains("workspace_only"));
1926 assert!(!crates.contains("metadata_only"));
1927 assert!(!crates.contains("serde"));
1928 }
1929
1930 #[test]
1931 fn normalizes_rust_package_name_from_cargo_toml() {
1932 let tempdir = TempDir::new().expect("tempdir");
1933 fs::write(
1934 tempdir.path().join("Cargo.toml"),
1935 r#"
1936[package]
1937name = "my-crate"
1938"#,
1939 )
1940 .expect("cargo toml");
1941
1942 assert_eq!(
1943 load_rust_self_crate_name(tempdir.path()).as_deref(),
1944 Some("my_crate")
1945 );
1946 }
1947
1948 #[test]
1949 fn rust_grouped_imports_register_named_bare_bindings() {
1950 let mut extracted = ExtractedImports::default();
1951
1952 parse_import_statement(
1953 "rust",
1954 "use std::collections::{HashMap, HashSet as Set};",
1955 "src/lib.rs",
1956 &ImportResolutionContext::default(),
1957 &mut extracted,
1958 );
1959
1960 let hash_map = extracted
1961 .bindings
1962 .bare
1963 .get("HashMap")
1964 .expect("HashMap binding");
1965 assert_eq!(hash_map.module, "std::collections");
1966 assert_eq!(hash_map.callee_name, "HashMap");
1967 let set = extracted.bindings.bare.get("Set").expect("Set binding");
1968 assert_eq!(set.module, "std::collections");
1969 assert_eq!(set.callee_name, "HashSet");
1970 assert_eq!(
1971 extracted.bindings.member.get("Set").map(String::as_str),
1972 Some("std::collections::HashSet")
1973 );
1974 assert!(extracted.bindings.external_roots.contains_key("std"));
1975 }
1976
1977 #[test]
1978 fn rust_glob_imports_do_not_register_individual_bare_bindings() {
1979 let mut extracted = ExtractedImports::default();
1980
1981 parse_import_statement(
1982 "rust",
1983 "use std::collections::*;",
1984 "src/lib.rs",
1985 &ImportResolutionContext::default(),
1986 &mut extracted,
1987 );
1988
1989 assert!(extracted.bindings.bare.is_empty());
1990 assert!(extracted.bindings.member.is_empty());
1991 }
1992
1993 #[test]
1994 fn php_grouped_imports_register_concrete_member_bindings() {
1995 let mut extracted = ExtractedImports::default();
1996
1997 parse_import_statement(
1998 "php",
1999 r"use Vendor\Pkg\{Client, Helper as H};",
2000 "src/sample.php",
2001 &ImportResolutionContext::default(),
2002 &mut extracted,
2003 );
2004
2005 assert!(
2006 extracted
2007 .imports
2008 .iter()
2009 .any(|import| import.module_name == r"Vendor\Pkg\Client")
2010 );
2011 assert!(
2012 extracted
2013 .imports
2014 .iter()
2015 .any(|import| import.module_name == r"Vendor\Pkg\Helper")
2016 );
2017 assert_eq!(
2018 extracted.bindings.member.get("Client").map(String::as_str),
2019 Some(r"Vendor\Pkg\Client")
2020 );
2021 assert_eq!(
2022 extracted.bindings.member.get("H").map(String::as_str),
2023 Some(r"Vendor\Pkg\Helper")
2024 );
2025 }
2026
2027 #[test]
2028 fn php_grouped_function_imports_register_concrete_bare_bindings() {
2029 let mut extracted = ExtractedImports::default();
2030
2031 parse_import_statement(
2032 "php",
2033 r"use function Vendor\Pkg\{work, helper as do_help};",
2034 "src/sample.php",
2035 &ImportResolutionContext::default(),
2036 &mut extracted,
2037 );
2038
2039 assert!(
2040 extracted
2041 .imports
2042 .iter()
2043 .any(|import| import.module_name == r"Vendor\Pkg\work")
2044 );
2045 let work = extracted
2046 .bindings
2047 .bare
2048 .get("work")
2049 .expect("function binding");
2050 assert_eq!(work.module, r"Vendor\Pkg");
2051 assert_eq!(work.callee_name, "work");
2052 let helper = extracted
2053 .bindings
2054 .bare
2055 .get("do_help")
2056 .expect("aliased function binding");
2057 assert_eq!(helper.module, r"Vendor\Pkg");
2058 assert_eq!(helper.callee_name, "helper");
2059 }
2060
2061 #[test]
2062 fn php_grouped_const_imports_preserve_aliases() {
2063 let mut extracted = ExtractedImports::default();
2064
2065 parse_import_statement(
2066 "php",
2067 r"use const Vendor\Pkg\{VALUE as V};",
2068 "src/sample.php",
2069 &ImportResolutionContext::default(),
2070 &mut extracted,
2071 );
2072
2073 assert_eq!(
2074 extracted.bindings.member.get("V").map(String::as_str),
2075 Some(r"Vendor\Pkg\VALUE")
2076 );
2077 }
2078
2079 #[test]
2080 fn loads_elixir_mix_lock_first_quoted_dependency_per_line() {
2081 let tempdir = TempDir::new().expect("tempdir");
2082 fs::write(
2083 tempdir.path().join("mix.lock"),
2084 r#"%{
2085 "jason": {:hex, :jason, "1.4.4", "checksum", [:mix], [], "hexpm", "repo"},
2086 "httpoison": {:hex, :httpoison, "2.2.1", "checksum", [:mix], [], "hexpm", "repo"}
2087}"#,
2088 )
2089 .expect("mix.lock");
2090
2091 let deps = load_elixir_dependency_names(tempdir.path());
2092
2093 assert!(deps.contains("jason"));
2094 assert!(deps.contains("httpoison"));
2095 assert!(!deps.contains("1"));
2096 assert!(!deps.contains("hexpm"));
2097 }
2098
2099 #[test]
2100 fn bundled_import_root_data_loads_known_mappings() {
2101 assert_eq!(ruby_require_root("json"), Some("JSON"));
2102 assert_eq!(ruby_require_root("unknown_gem"), None);
2103
2104 let roots = elixir_dependency_roots("jason").expect("jason roots");
2105 assert_eq!(roots.first().map(String::as_str), Some("Jason"));
2106 assert_eq!(roots.len(), 1);
2107 assert!(elixir_dependency_roots("unknown_dep").is_none());
2108 }
2109
2110 #[test]
2111 fn runtime_import_root_overrides_take_precedence() {
2112 let tempdir = TempDir::new().expect("tempdir");
2113 fs::write(
2114 tempdir.path().join("mix.exs"),
2115 r#"
2116defp deps do
2117 [
2118 {:jason, "~> 1.4"}
2119 ]
2120end
2121"#,
2122 )
2123 .expect("mix.exs");
2124
2125 let context = build_import_resolution_context_with_overrides(
2126 tempdir.path(),
2127 &[],
2128 HashMap::from([("json".to_string(), "RuntimeJSON".to_string())]),
2129 HashMap::from([
2130 ("Jason".to_string(), "RuntimeJason".to_string()),
2131 ("RuntimeOnly".to_string(), "RuntimeOnly".to_string()),
2132 ]),
2133 );
2134
2135 let mut extracted = ExtractedImports::default();
2136 parse_import_statement(
2137 "ruby",
2138 r#"require "json""#,
2139 "app.rb",
2140 &context,
2141 &mut extracted,
2142 );
2143 assert!(
2144 extracted
2145 .bindings
2146 .external_roots
2147 .contains_key("RuntimeJSON")
2148 );
2149 assert!(!extracted.bindings.external_roots.contains_key("JSON"));
2150
2151 let mut bindings = ImportBindings::default();
2152 seed_import_bindings("elixir", &context, &mut bindings);
2153 assert_eq!(
2154 bindings
2155 .external_roots
2156 .get("Jason")
2157 .map(|binding| binding.module.as_str()),
2158 Some("RuntimeJason")
2159 );
2160 assert_eq!(
2161 bindings
2162 .external_roots
2163 .get("RuntimeOnly")
2164 .map(|binding| binding.module.as_str()),
2165 Some("RuntimeOnly")
2166 );
2167 }
2168
2169 #[test]
2170 fn go_default_package_alias_uses_last_segment_before_version_suffix() {
2171 assert_eq!(go_default_package_alias("gopkg.in/yaml.v3"), "yaml");
2172 assert_eq!(
2173 go_default_package_alias("github.com/acme/api-client/"),
2174 "api_client"
2175 );
2176 }
2177
2178 #[test]
2179 fn go_backtick_imports_register_external_bindings() {
2180 let import_context = ImportResolutionContext {
2181 go_module_path: Some("example.com/local".to_string()),
2182 ..Default::default()
2183 };
2184 let mut extracted = ExtractedImports::default();
2185
2186 parse_import_statement(
2187 "go",
2188 "import api `github.com/acme/api-client`",
2189 "main.go",
2190 &import_context,
2191 &mut extracted,
2192 );
2193
2194 assert_eq!(
2195 extracted
2196 .imports
2197 .first()
2198 .map(|import| import.module_name.as_str()),
2199 Some("github.com/acme/api-client")
2200 );
2201 assert_eq!(
2202 extracted.bindings.member.get("api").map(String::as_str),
2203 Some("github.com/acme/api-client")
2204 );
2205 }
2206
2207 #[test]
2208 fn csharp_declared_types_includes_structs() {
2209 let names = csharp_declared_types(
2210 "public struct Point {} class Sample {} interface IThing {} enum Mode {} record Data;",
2211 );
2212
2213 assert!(names.iter().any(|name| name == "Point"));
2214 assert!(names.iter().any(|name| name == "Sample"));
2215 assert!(names.iter().any(|name| name == "IThing"));
2216 assert!(names.iter().any(|name| name == "Mode"));
2217 assert!(names.iter().any(|name| name == "Data"));
2218 }
2219
2220 #[test]
2221 fn empty_php_fully_qualified_namespace_stays_unresolved() {
2222 let target = resolve_external_callee(
2223 &ImportResolutionContext::default(),
2224 &ImportBindings::default(),
2225 &[],
2226 "helper",
2227 Some(""),
2228 Some("\\"),
2229 false,
2230 );
2231
2232 assert!(target.is_none());
2233 }
2234
2235 #[test]
2236 fn php_local_fully_qualified_class_stays_unresolved() {
2237 let mut import_context = ImportResolutionContext::default();
2238 import_context
2239 .php_local_symbols
2240 .insert(r"App\Services\Mailer".to_string());
2241
2242 let target = resolve_external_callee(
2243 &import_context,
2244 &ImportBindings::default(),
2245 &[],
2246 "send",
2247 Some("App"),
2248 Some(r"\App\Services\Mailer"),
2249 false,
2250 );
2251
2252 assert!(target.is_none());
2253 }
2254
2255 #[test]
2256 fn php_local_fully_qualified_function_stays_unresolved() {
2257 let mut import_context = ImportResolutionContext::default();
2258 import_context
2259 .php_local_symbols
2260 .insert(r"App\Helpers\render".to_string());
2261
2262 let target = resolve_external_callee(
2263 &import_context,
2264 &ImportBindings::default(),
2265 &[],
2266 "render",
2267 Some("App"),
2268 Some(r"\App\Helpers"),
2269 false,
2270 );
2271
2272 assert!(target.is_none());
2273 }
2274}