Skip to main content

gobby_code/index/
import_resolution.rs

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        // Multiple wildcard imports make the source module ambiguous, so fail closed.
255        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        // Glob imports are intentionally not expanded because exported names are unknown here.
930        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            // Glob imports are intentionally not expanded because exported names are unknown here.
955            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
1709/// Returns a curated Ruby `require` to constant-root mapping.
1710///
1711/// Keep the bundled map small and documented in
1712/// `assets/import_roots/ruby_require_roots.json`; use runtime overrides when
1713/// constructing `ImportResolutionContext` for project-specific roots.
1714fn 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
1757/// Returns curated Elixir dependency module roots.
1758///
1759/// Hex package names do not mechanically map to Elixir module roots. Maintain
1760/// the bundled map in `assets/import_roots/elixir_dependency_roots.json`; use
1761/// runtime overrides when constructing `ImportResolutionContext` if discovery
1762/// can provide more precise roots.
1763fn 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}