Skip to main content

gobby_code/index/import_resolution/
context.rs

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