Skip to main content

lean_ctx_syntax/import_resolver/
languages.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Per-language import resolvers.
4//!
5//! Each `resolve_*` maps a language's import specifier to a project file path and
6//! is dispatched from the parent module's `resolve_one`. Language-private helpers (path
7//! probing, stdlib checks) stay private to this module.
8
9#[allow(clippy::wildcard_imports)]
10use super::*;
11
12// ---------------------------------------------------------------------------
13// TypeScript / JavaScript
14// ---------------------------------------------------------------------------
15
16pub fn resolve_ts(
17    imp: &ImportInfo,
18    file_path: &str,
19    ctx: &ResolverContext,
20) -> (Option<String>, bool) {
21    let source = &imp.source;
22
23    if source.starts_with('.') {
24        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
25        let resolved = dir.join(source);
26        let normalized = normalize_path(&resolved);
27
28        if let Some(found) = try_ts_with_js_remap(&normalized, ctx) {
29            return (Some(found), false);
30        }
31        return (None, false);
32    }
33
34    if let Some(mapped) = resolve_tsconfig_path(source, ctx) {
35        return (Some(mapped), false);
36    }
37
38    (None, true)
39}
40
41/// Resolve a TS/JS import path, handling the TypeScript convention where
42/// `.js` specifiers in `.ts` files resolve to `.ts` sources.
43/// See: <https://www.typescriptlang.org/docs/handbook/modules/reference.html#relative-file-path-resolution>
44fn try_ts_with_js_remap(base: &str, ctx: &ResolverContext) -> Option<String> {
45    const JS_TO_TS: &[(&str, &[&str])] = &[
46        (".js", &[".ts", ".tsx"]),
47        (".jsx", &[".tsx", ".ts"]),
48        (".mjs", &[".mts"]),
49        (".cjs", &[".cts"]),
50    ];
51
52    for &(js_ext, ts_exts) in JS_TO_TS {
53        if let Some(stem) = base.strip_suffix(js_ext) {
54            for ts_ext in ts_exts {
55                let candidate = format!("{stem}{ts_ext}");
56                if ctx.file_exists(&candidate) {
57                    return Some(candidate);
58                }
59            }
60        }
61    }
62
63    try_ts_extensions(base, ctx)
64}
65
66fn try_ts_extensions(base: &str, ctx: &ResolverContext) -> Option<String> {
67    let extensions = [".ts", ".tsx", ".js", ".jsx", ".d.ts"];
68
69    if ctx.file_exists(base) {
70        return Some(base.to_string());
71    }
72
73    for ext in &extensions {
74        let with_ext = format!("{base}{ext}");
75        if ctx.file_exists(&with_ext) {
76            return Some(with_ext);
77        }
78    }
79
80    let index_extensions = ["index.ts", "index.tsx", "index.js", "index.jsx"];
81    for idx in &index_extensions {
82        let index_path = format!("{base}/{idx}");
83        if ctx.file_exists(&index_path) {
84            return Some(index_path);
85        }
86    }
87
88    None
89}
90
91fn resolve_tsconfig_path(source: &str, ctx: &ResolverContext) -> Option<String> {
92    for (pattern, target) in &ctx.tsconfig_paths {
93        let prefix = pattern.trim_end_matches('*');
94        if let Some(remainder) = source.strip_prefix(prefix) {
95            let target_base = target.trim_end_matches('*');
96            let candidate = format!("{target_base}{remainder}");
97            if let Some(found) = try_ts_with_js_remap(&candidate, ctx) {
98                return Some(found);
99            }
100        }
101    }
102    None
103}
104
105// ---------------------------------------------------------------------------
106// Rust
107// ---------------------------------------------------------------------------
108
109pub fn resolve_rust(
110    imp: &ImportInfo,
111    file_path: &str,
112    ctx: &ResolverContext,
113) -> (Option<String>, bool) {
114    let source = &imp.source;
115
116    if source.starts_with("crate::")
117        || source.starts_with("super::")
118        || source.starts_with("self::")
119    {
120        let cleaned = source.replace("crate::", "").replace("self::", "");
121
122        let resolved = if source.starts_with("super::") {
123            let dir = Path::new(file_path).parent().and_then(|p| p.parent());
124            match dir {
125                Some(d) => {
126                    let rest = source.trim_start_matches("super::");
127                    d.join(rest.replace("::", "/"))
128                        .to_string_lossy()
129                        .to_string()
130                }
131                None => cleaned.replace("::", "/"),
132            }
133        } else {
134            cleaned.replace("::", "/")
135        };
136
137        if let Some(found) = try_rust_paths(&resolved, ctx) {
138            return (Some(found), false);
139        }
140        return (None, false);
141    }
142
143    let parts: Vec<&str> = source.split("::").collect();
144    if parts.is_empty() {
145        return (None, true);
146    }
147
148    let is_external = !source.starts_with("crate")
149        && !source.starts_with("super")
150        && !source.starts_with("self")
151        && !ctx.file_paths.iter().any(|f| {
152            let stem = Path::new(f)
153                .file_stem()
154                .and_then(|s| s.to_str())
155                .unwrap_or("");
156            stem == parts[0] || f.contains(&format!("/{}/", parts[0]))
157        });
158
159    if is_external {
160        return (None, true);
161    }
162
163    let as_path = source.replace("::", "/");
164    if let Some(found) = try_rust_paths(&as_path, ctx) {
165        return (Some(found), false);
166    }
167
168    (None, is_external)
169}
170
171fn try_rust_paths(base: &str, ctx: &ResolverContext) -> Option<String> {
172    let prefixes = ["", "src/", "rust/src/"];
173    for prefix in &prefixes {
174        let candidate = format!("{prefix}{base}.rs");
175        if ctx.file_exists(&candidate) {
176            return Some(candidate);
177        }
178        let mod_candidate = format!("{prefix}{base}/mod.rs");
179        if ctx.file_exists(&mod_candidate) {
180            return Some(mod_candidate);
181        }
182    }
183
184    let parts: Vec<&str> = base.rsplitn(2, '/').collect();
185    if parts.len() == 2 {
186        let parent = parts[1];
187        for prefix in &prefixes {
188            let candidate = format!("{prefix}{parent}.rs");
189            if ctx.file_exists(&candidate) {
190                return Some(candidate);
191            }
192        }
193    }
194
195    None
196}
197
198// ---------------------------------------------------------------------------
199// Python
200// ---------------------------------------------------------------------------
201
202pub fn resolve_python(
203    imp: &ImportInfo,
204    file_path: &str,
205    ctx: &ResolverContext,
206) -> (Option<String>, bool) {
207    let source = &imp.source;
208
209    if source.starts_with('.') {
210        let dot_count = source.chars().take_while(|c| *c == '.').count();
211        let module_part = &source[dot_count..];
212
213        let mut dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
214        for _ in 1..dot_count {
215            dir = dir.parent().unwrap_or(Path::new(""));
216        }
217
218        let as_path = module_part.replace('.', "/");
219        let base = if as_path.is_empty() {
220            dir.to_string_lossy().to_string()
221        } else {
222            format!("{}/{as_path}", dir.display())
223        };
224
225        if let Some(found) = try_python_paths(&base, ctx) {
226            return (Some(found), false);
227        }
228        return (None, false);
229    }
230
231    let as_path = source.replace('.', "/");
232
233    if let Some(found) = try_python_paths(&as_path, ctx) {
234        return (Some(found), false);
235    }
236
237    let is_stdlib = is_python_stdlib(source);
238    (
239        None,
240        is_stdlib || !ctx.file_paths.iter().any(|f| f.contains(&as_path)),
241    )
242}
243
244fn try_python_paths(base: &str, ctx: &ResolverContext) -> Option<String> {
245    let py_file = format!("{base}.py");
246    if ctx.file_exists(&py_file) {
247        return Some(py_file);
248    }
249
250    let init_file = format!("{base}/__init__.py");
251    if ctx.file_exists(&init_file) {
252        return Some(init_file);
253    }
254
255    let prefixes = ["src/", "lib/"];
256    for prefix in &prefixes {
257        let candidate = format!("{prefix}{base}.py");
258        if ctx.file_exists(&candidate) {
259            return Some(candidate);
260        }
261        let init = format!("{prefix}{base}/__init__.py");
262        if ctx.file_exists(&init) {
263            return Some(init);
264        }
265    }
266
267    None
268}
269
270fn is_python_stdlib(module: &str) -> bool {
271    let first = module.split('.').next().unwrap_or(module);
272    matches!(
273        first,
274        "os" | "sys"
275            | "json"
276            | "re"
277            | "math"
278            | "datetime"
279            | "typing"
280            | "collections"
281            | "itertools"
282            | "functools"
283            | "pathlib"
284            | "io"
285            | "abc"
286            | "enum"
287            | "dataclasses"
288            | "logging"
289            | "unittest"
290            | "argparse"
291            | "subprocess"
292            | "threading"
293            | "multiprocessing"
294            | "socket"
295            | "http"
296            | "urllib"
297            | "hashlib"
298            | "hmac"
299            | "secrets"
300            | "time"
301            | "copy"
302            | "pprint"
303            | "textwrap"
304            | "shutil"
305            | "tempfile"
306            | "glob"
307            | "fnmatch"
308            | "contextlib"
309            | "inspect"
310            | "importlib"
311            | "pickle"
312            | "shelve"
313            | "csv"
314            | "configparser"
315            | "struct"
316            | "codecs"
317            | "string"
318            | "difflib"
319            | "ast"
320            | "dis"
321            | "traceback"
322            | "warnings"
323            | "concurrent"
324            | "asyncio"
325            | "signal"
326            | "select"
327    )
328}
329
330// ---------------------------------------------------------------------------
331// Go
332// ---------------------------------------------------------------------------
333
334pub fn resolve_go(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
335    let source = &imp.source;
336
337    if let Some(ref go_mod) = ctx.go_module
338        && source.starts_with(go_mod.as_str())
339    {
340        let relative = source.strip_prefix(go_mod.as_str()).unwrap_or(source);
341        let relative = relative.trim_start_matches('/');
342
343        if let Some(found) = try_go_package(relative, ctx) {
344            return (Some(found), false);
345        }
346        return (None, false);
347    }
348
349    if let Some(found) = try_go_package(source, ctx) {
350        return (Some(found), false);
351    }
352
353    (None, true)
354}
355
356fn try_go_package(pkg_path: &str, ctx: &ResolverContext) -> Option<String> {
357    for file in &ctx.file_paths {
358        let p = Path::new(file.as_str());
359        if p.extension().and_then(|e| e.to_str()) != Some("go") {
360            continue;
361        }
362        if file.ends_with("_test.go") {
363            continue;
364        }
365        let dir = p.parent()?.to_string_lossy();
366        if dir == pkg_path || dir.ends_with(pkg_path) {
367            return Some(file.clone());
368        }
369    }
370    None
371}
372
373// ---------------------------------------------------------------------------
374// Java
375// ---------------------------------------------------------------------------
376
377pub fn resolve_java(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
378    let source = &imp.source;
379
380    if source.starts_with("java.") || source.starts_with("javax.") || source.starts_with("sun.") {
381        return (None, true);
382    }
383
384    let parts: Vec<&str> = source.rsplitn(2, '.').collect();
385    if parts.len() < 2 {
386        return (None, true);
387    }
388
389    let class_name = parts[0];
390    let package_path = parts[1].replace('.', "/");
391    let file_path = format!("{package_path}/{class_name}.java");
392
393    let search_roots = ["", "src/main/java/", "src/", "app/src/main/java/"];
394    for root in &search_roots {
395        let candidate = format!("{root}{file_path}");
396        if ctx.file_exists(&candidate) {
397            return (Some(candidate), false);
398        }
399    }
400
401    (
402        None,
403        !ctx.file_paths.iter().any(|f| f.contains(&package_path)),
404    )
405}
406
407// ---------------------------------------------------------------------------
408// Kotlin
409// ---------------------------------------------------------------------------
410
411pub fn resolve_kotlin(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
412    let source = &imp.source;
413
414    if source.starts_with("java.")
415        || source.starts_with("javax.")
416        || source.starts_with("kotlin.")
417        || source.starts_with("kotlinx.")
418        || source.starts_with("android.")
419        || source.starts_with("androidx.")
420        || source.starts_with("org.junit.")
421        || source.starts_with("org.jetbrains.")
422    {
423        return (None, true);
424    }
425
426    let parts: Vec<&str> = source.rsplitn(2, '.').collect();
427    if parts.len() < 2 {
428        return (None, true);
429    }
430
431    let class_name = parts[0];
432    let package_path = parts[1].replace('.', "/");
433
434    let search_roots = [
435        "",
436        "src/main/kotlin/",
437        "src/main/java/",
438        "src/",
439        "app/src/main/kotlin/",
440        "app/src/main/java/",
441        "src/commonMain/kotlin/",
442    ];
443
444    for root in &search_roots {
445        for ext in &["kt", "kts", "java"] {
446            let candidate = format!("{root}{package_path}/{class_name}.{ext}");
447            if ctx.file_exists(&candidate) {
448                return (Some(candidate), false);
449            }
450        }
451    }
452
453    (
454        None,
455        !ctx.file_paths.iter().any(|f| f.contains(&package_path)),
456    )
457}
458
459// ---------------------------------------------------------------------------
460// C / C++
461// ---------------------------------------------------------------------------
462
463pub fn resolve_c_like(
464    imp: &ImportInfo,
465    file_path: &str,
466    ctx: &ResolverContext,
467) -> (Option<String>, bool) {
468    let source = imp.source.trim();
469    if source.is_empty() {
470        return (None, true);
471    }
472
473    let try_prefixes = |prefixes: &[&str], rel: &str| -> Option<String> {
474        let rel = rel.trim_start_matches("./").trim_start_matches('/');
475        let mut candidates: Vec<String> = vec![rel.to_string()];
476        for ext in [".h", ".hpp", ".c", ".cpp"] {
477            if !rel.ends_with(ext) {
478                candidates.push(format!("{rel}{ext}"));
479            }
480        }
481        for prefix in prefixes {
482            for c in &candidates {
483                let p = format!("{prefix}{c}");
484                if ctx.file_exists(&p) {
485                    return Some(p);
486                }
487            }
488        }
489        None
490    };
491
492    if source.starts_with('.') {
493        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
494        let dir_prefix = if dir.as_os_str().is_empty() {
495            String::new()
496        } else {
497            format!("{}/", dir.to_string_lossy())
498        };
499        if let Some(found) = try_prefixes(&[dir_prefix.as_str()], source) {
500            return (Some(found), false);
501        }
502        return (None, false);
503    }
504
505    if ctx.file_exists(source) {
506        return (Some(source.to_string()), false);
507    }
508
509    if let Some(found) = try_prefixes(&["", "include/", "src/"], source) {
510        return (Some(found), false);
511    }
512
513    (None, true)
514}
515
516// ---------------------------------------------------------------------------
517// Ruby
518// ---------------------------------------------------------------------------
519
520pub fn resolve_ruby(
521    imp: &ImportInfo,
522    file_path: &str,
523    ctx: &ResolverContext,
524) -> (Option<String>, bool) {
525    let source = imp.source.trim();
526    if source.is_empty() {
527        return (None, true);
528    }
529    let source_rel = source.trim_start_matches("./").trim_start_matches('/');
530
531    let try_prefixes = |prefixes: &[&str]| -> Option<String> {
532        let mut candidates: Vec<String> = vec![source_rel.to_string()];
533        if !Path::new(source_rel)
534            .extension()
535            .is_some_and(|e| e.eq_ignore_ascii_case("rb"))
536        {
537            candidates.push(format!("{source_rel}.rb"));
538        }
539        for prefix in prefixes {
540            for c in &candidates {
541                let p = format!("{prefix}{c}");
542                if ctx.file_exists(&p) {
543                    return Some(p);
544                }
545            }
546        }
547        None
548    };
549
550    if source.starts_with('.') || source_rel.contains('/') {
551        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
552        let dir_prefix = if dir.as_os_str().is_empty() {
553            String::new()
554        } else {
555            format!("{}/", dir.to_string_lossy())
556        };
557        if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
558            return (Some(found), false);
559        }
560        if let Some(found) = try_prefixes(&["", "lib/", "src/"]) {
561            return (Some(found), false);
562        }
563        return (None, false);
564    }
565
566    (None, true)
567}
568
569// ---------------------------------------------------------------------------
570// PHP
571// ---------------------------------------------------------------------------
572
573pub fn resolve_php(
574    imp: &ImportInfo,
575    file_path: &str,
576    ctx: &ResolverContext,
577) -> (Option<String>, bool) {
578    let source = imp.source.trim();
579    if source.is_empty() {
580        return (None, true);
581    }
582    if source.starts_with("http://") || source.starts_with("https://") {
583        return (None, true);
584    }
585    let source_rel = source.trim_start_matches("./").trim_start_matches('/');
586
587    let try_prefixes = |prefixes: &[&str]| -> Option<String> {
588        let mut candidates: Vec<String> = vec![source_rel.to_string()];
589        if !Path::new(source_rel)
590            .extension()
591            .is_some_and(|e| e.eq_ignore_ascii_case("php"))
592        {
593            candidates.push(format!("{source_rel}.php"));
594        }
595        for prefix in prefixes {
596            for c in &candidates {
597                let p = format!("{prefix}{c}");
598                if ctx.file_exists(&p) {
599                    return Some(p);
600                }
601            }
602        }
603        None
604    };
605
606    if source.starts_with('.') || source.starts_with('/') || source_rel.contains('/') {
607        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
608        let dir_prefix = if dir.as_os_str().is_empty() {
609            String::new()
610        } else {
611            format!("{}/", dir.to_string_lossy())
612        };
613        if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
614            return (Some(found), false);
615        }
616        if let Some(found) = try_prefixes(&["", "src/", "lib/"]) {
617            return (Some(found), false);
618        }
619        return (None, false);
620    }
621
622    (None, true)
623}
624
625// ---------------------------------------------------------------------------
626// Bash
627// ---------------------------------------------------------------------------
628
629pub fn resolve_bash(
630    imp: &ImportInfo,
631    file_path: &str,
632    ctx: &ResolverContext,
633) -> (Option<String>, bool) {
634    let source = imp.source.trim();
635    if source.is_empty() {
636        return (None, true);
637    }
638    let source_rel = source.trim_start_matches("./").trim_start_matches('/');
639
640    let try_prefixes = |prefixes: &[&str]| -> Option<String> {
641        let mut candidates: Vec<String> = vec![source_rel.to_string()];
642        if !Path::new(source_rel)
643            .extension()
644            .is_some_and(|e| e.eq_ignore_ascii_case("sh"))
645        {
646            candidates.push(format!("{source_rel}.sh"));
647        }
648        for prefix in prefixes {
649            for c in &candidates {
650                let p = format!("{prefix}{c}");
651                if ctx.file_exists(&p) {
652                    return Some(p);
653                }
654            }
655        }
656        None
657    };
658
659    if source.starts_with('.') || source.starts_with('/') || source_rel.contains('/') {
660        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
661        let dir_prefix = if dir.as_os_str().is_empty() {
662            String::new()
663        } else {
664            format!("{}/", dir.to_string_lossy())
665        };
666        if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
667            return (Some(found), false);
668        }
669        if let Some(found) = try_prefixes(&["", "scripts/", "bin/"]) {
670            return (Some(found), false);
671        }
672        return (None, false);
673    }
674
675    (None, true)
676}
677
678// ---------------------------------------------------------------------------
679// Dart
680// ---------------------------------------------------------------------------
681
682pub fn resolve_dart(
683    imp: &ImportInfo,
684    file_path: &str,
685    ctx: &ResolverContext,
686) -> (Option<String>, bool) {
687    let source = imp.source.trim();
688    if source.is_empty() {
689        return (None, true);
690    }
691    if source.starts_with("dart:") {
692        return (None, true);
693    }
694
695    let try_prefixes = |prefixes: &[&str], rel: &str| -> Option<String> {
696        let rel = rel.trim_start_matches("./").trim_start_matches('/').trim();
697        let mut candidates: Vec<String> = vec![rel.to_string()];
698        if !Path::new(rel)
699            .extension()
700            .is_some_and(|e| e.eq_ignore_ascii_case("dart"))
701        {
702            candidates.push(format!("{rel}.dart"));
703        }
704        for prefix in prefixes {
705            for c in &candidates {
706                let p = format!("{prefix}{c}");
707                if ctx.file_exists(&p) {
708                    return Some(p);
709                }
710            }
711        }
712        None
713    };
714
715    if source.starts_with("package:") {
716        if let Some(pkg) = ctx.dart_package.as_deref() {
717            let prefix = format!("package:{pkg}/");
718            if let Some(rest) = source.strip_prefix(&prefix) {
719                if let Some(found) = try_prefixes(&["lib/", ""], rest) {
720                    return (Some(found), false);
721                }
722                return (None, false);
723            }
724        }
725        return (None, true);
726    }
727
728    if source.starts_with('.') || source.starts_with('/') {
729        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
730        let dir_prefix = if dir.as_os_str().is_empty() {
731            String::new()
732        } else {
733            format!("{}/", dir.to_string_lossy())
734        };
735        if let Some(found) = try_prefixes(&[dir_prefix.as_str()], source) {
736            return (Some(found), false);
737        }
738        if let Some(found) = try_prefixes(&["", "lib/"], source) {
739            return (Some(found), false);
740        }
741        return (None, false);
742    }
743
744    (None, true)
745}
746
747// ---------------------------------------------------------------------------
748// GDScript (Godot)
749// ---------------------------------------------------------------------------
750
751/// Resolves GDScript `extends "res://…"` and `preload`/`load` resource paths.
752/// `res://` is anchored at the project root; `user://` is a runtime data path
753/// (never a source file); other paths are resolved relative to the importer.
754pub fn resolve_gd(
755    imp: &ImportInfo,
756    file_path: &str,
757    ctx: &ResolverContext,
758) -> (Option<String>, bool) {
759    let source = imp.source.trim();
760    if source.is_empty() {
761        return (None, true);
762    }
763
764    // Probe the verbatim path first (the common `preload("…/Foo.tscn")` form
765    // already carries its extension), then Godot resource suffixes for imports
766    // that omit one (`extends "res://actors/Player"`). #315
767    let try_paths = |rel: &str| -> Option<String> {
768        let rel = rel.trim();
769        if ctx.file_exists(rel) {
770            return Some(rel.to_string());
771        }
772        if Path::new(rel).extension().is_none() {
773            for ext in ["gd", "tscn", "tres"] {
774                let candidate = format!("{rel}.{ext}");
775                if ctx.file_exists(&candidate) {
776                    return Some(candidate);
777                }
778            }
779        }
780        None
781    };
782
783    if let Some(rest) = source.strip_prefix("res://") {
784        let rel = rest.trim_start_matches('/');
785        if let Some(found) = try_paths(rel) {
786            return (Some(found), false);
787        }
788        // `res://` always names an intra-project resource. When the target is a
789        // concrete file (carries a resource extension) that simply isn't indexed
790        // yet — e.g. a `.tscn` scene before scene indexing exists (#316) — still
791        // emit the edge to the declared path so scene references survive. #315
792        if !rel.is_empty() && Path::new(rel).extension().is_some() {
793            return (Some(rel.to_string()), false);
794        }
795        return (None, false);
796    }
797
798    // Runtime user data path — not a project source file.
799    if source.starts_with("user://") {
800        return (None, true);
801    }
802
803    if source.starts_with('.') || source.contains('/') {
804        let stripped = source.trim_start_matches("./");
805        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
806        let joined = if dir.as_os_str().is_empty() {
807            stripped.to_string()
808        } else {
809            format!("{}/{stripped}", dir.to_string_lossy())
810        };
811        if let Some(found) = try_paths(&joined) {
812            return (Some(found), false);
813        }
814        return (try_paths(stripped), false);
815    }
816
817    (None, true)
818}
819
820// ---------------------------------------------------------------------------
821// Lua / Luau
822// ---------------------------------------------------------------------------
823
824/// Resolves Lua `require("a.b.c")` (dotted module path, package.path style) and
825/// Luau `require("a/b")` / `require("./a")` (slash/relative paths) to a project
826/// file. Dotted specifiers map `.` -> `/`; pathy specifiers are used verbatim.
827/// Candidates: `<rel>.lua`, `<rel>/init.lua` (and `.luau`), probed relative to
828/// the importer first, then the project root and common source roots.
829pub fn resolve_lua(
830    imp: &ImportInfo,
831    file_path: &str,
832    ctx: &ResolverContext,
833) -> (Option<String>, bool) {
834    let source = imp.source.trim();
835    if source.is_empty() {
836        return (None, true);
837    }
838
839    // Pathy specifiers (Luau relative/slash form) are used as-is; pure dotted
840    // module names (`a.b.c`) map dots to directory separators (package.path).
841    let is_pathy = source.contains('/') || source.starts_with('.');
842    let rel = if is_pathy {
843        source
844            .trim_start_matches("./")
845            .trim_start_matches('/')
846            .to_string()
847    } else {
848        source.replace('.', "/")
849    };
850    if rel.is_empty() {
851        return (None, true);
852    }
853
854    let try_paths = |base: &str| -> Option<String> {
855        let base = base.trim_start_matches('/');
856        if ctx.file_exists(base) {
857            return Some(base.to_string());
858        }
859        for ext in ["lua", "luau"] {
860            let file = format!("{base}.{ext}");
861            if ctx.file_exists(&file) {
862                return Some(file);
863            }
864            let init = format!("{base}/init.{ext}");
865            if ctx.file_exists(&init) {
866                return Some(init);
867            }
868        }
869        None
870    };
871
872    let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
873    let relative_to_importer = || -> Option<String> {
874        if dir.as_os_str().is_empty() {
875            return None;
876        }
877        try_paths(&format!("{}/{rel}", dir.to_string_lossy()))
878    };
879    // Project root + common Lua source roots (package.path style).
880    let from_roots = || -> Option<String> {
881        try_paths(&rel).or_else(|| {
882            ["src/", "lua/", "lib/"]
883                .iter()
884                .find_map(|prefix| try_paths(&format!("{prefix}{rel}")))
885        })
886    };
887
888    // Luau slash/relative requires are importer-relative first; standard Lua
889    // dotted module names resolve from the project/source roots first.
890    let found = if is_pathy {
891        relative_to_importer().or_else(from_roots)
892    } else {
893        from_roots().or_else(relative_to_importer)
894    };
895
896    match found {
897        Some(path) => (Some(path), false),
898        None => (None, true),
899    }
900}
901
902// ---------------------------------------------------------------------------
903// Zig
904// ---------------------------------------------------------------------------
905
906pub fn resolve_zig(
907    imp: &ImportInfo,
908    file_path: &str,
909    ctx: &ResolverContext,
910) -> (Option<String>, bool) {
911    let source = imp.source.trim();
912    if source.is_empty() {
913        return (None, true);
914    }
915    let source_rel = source.trim_start_matches("./").trim_start_matches('/');
916    if source_rel == "std" {
917        return (None, true);
918    }
919
920    let try_prefixes = |prefixes: &[&str]| -> Option<String> {
921        let mut candidates: Vec<String> = vec![source_rel.to_string()];
922        if !Path::new(source_rel)
923            .extension()
924            .is_some_and(|e| e.eq_ignore_ascii_case("zig"))
925        {
926            candidates.push(format!("{source_rel}.zig"));
927        }
928        for prefix in prefixes {
929            for c in &candidates {
930                let p = format!("{prefix}{c}");
931                if ctx.file_exists(&p) {
932                    return Some(p);
933                }
934            }
935        }
936        None
937    };
938
939    if source.starts_with('.')
940        || source_rel.contains('/')
941        || Path::new(source_rel)
942            .extension()
943            .is_some_and(|e| e.eq_ignore_ascii_case("zig"))
944    {
945        let dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
946        let dir_prefix = if dir.as_os_str().is_empty() {
947            String::new()
948        } else {
949            format!("{}/", dir.to_string_lossy())
950        };
951        if let Some(found) = try_prefixes(&[dir_prefix.as_str()]) {
952            return (Some(found), false);
953        }
954        if let Some(found) = try_prefixes(&["", "src/"]) {
955            return (Some(found), false);
956        }
957        return (None, false);
958    }
959
960    (None, true)
961}
962
963// ---------------------------------------------------------------------------
964// C#
965// ---------------------------------------------------------------------------
966
967pub fn resolve_csharp(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
968    let ns = imp.source.trim();
969    if ns.is_empty() {
970        return (None, true);
971    }
972
973    // 1) .NET BCL / common NuGet roots are external — resolved first so a `using`
974    //    such as `System.Text` or `System.IO` can never fall through to a local
975    //    folder that happens to share the trailing segment (`Text/`, `IO/`).
976    if is_csharp_external_namespace(ns) {
977        return (None, true);
978    }
979
980    let segs: Vec<&str> = ns.split('.').filter(|s| !s.is_empty()).collect();
981    if segs.is_empty() {
982        return (None, true);
983    }
984
985    // 2) `using A.B.C` names a *namespace*. The C# root namespace (the assembly's
986    //    default namespace, possibly multi-segment) is usually NOT a folder, so we
987    //    probe every trailing folder-suffix, longest (most specific) match first.
988    if let Some(file) = probe_csharp_namespace(&segs, ctx) {
989        return (Some(file), false);
990    }
991
992    // 3) `using A.B.C` may instead import the *type* `C` from namespace `A.B`:
993    //    drop the final segment and probe the parent namespace's folder.
994    if segs.len() >= 2
995        && let Some(file) = probe_csharp_namespace(&segs[..segs.len() - 1], ctx)
996    {
997        return (Some(file), false);
998    }
999
1000    // 4) Unresolved: treat as external so it neither creates a phantom edge nor
1001    //    is miscounted as a missing local dependency.
1002    (None, true)
1003}
1004
1005/// Probe trailing folder-suffixes of a namespace path against the C# namespace
1006/// index, returning the representative file of the longest (most specific) match.
1007/// `["MyApp", "Models"]` probes `MyApp/Models` then `Models`, so a root namespace
1008/// that isn't mirrored as a folder (the common case) still resolves.
1009fn probe_csharp_namespace(segs: &[&str], ctx: &ResolverContext) -> Option<String> {
1010    (0..segs.len())
1011        .map(|start| segs[start..].join("/"))
1012        .find_map(|key| ctx.csharp_namespace_file(&key).map(str::to_string))
1013}
1014
1015/// Roots of the .NET BCL and common NuGet packages. A `using` whose first
1016/// segment matches one of these is an external dependency (no local edge).
1017fn is_csharp_external_namespace(ns: &str) -> bool {
1018    const EXTERNAL_ROOTS: &[&str] = &[
1019        "System",
1020        "Microsoft",
1021        "Windows",
1022        "Mono",
1023        "Newtonsoft",
1024        "NUnit",
1025        "Xunit",
1026        "Moq",
1027        "Serilog",
1028        "AutoMapper",
1029        "MediatR",
1030        "FluentValidation",
1031        "Polly",
1032        "Castle",
1033        "Dapper",
1034        "RestSharp",
1035        "Google",
1036        "Amazon",
1037        "Azure",
1038        "Grpc",
1039        "Nito",
1040        "StackExchange",
1041    ];
1042    let root = ns.split('.').next().unwrap_or(ns);
1043    EXTERNAL_ROOTS.contains(&root)
1044}
1045
1046// ---------------------------------------------------------------------------
1047// Swift
1048// ---------------------------------------------------------------------------
1049
1050pub fn resolve_swift(
1051    imp: &ImportInfo,
1052    file_path: &str,
1053    ctx: &ResolverContext,
1054) -> (Option<String>, bool) {
1055    let source = &imp.source;
1056
1057    if matches!(
1058        source.as_str(),
1059        "Foundation" | "UIKit" | "SwiftUI" | "Combine" | "CoreData" | "Darwin"
1060    ) {
1061        return (None, true);
1062    }
1063
1064    let dir = Path::new(file_path)
1065        .parent()
1066        .map(|p| p.to_string_lossy().to_string())
1067        .unwrap_or_default();
1068
1069    let candidate = if dir.is_empty() {
1070        format!("{source}.swift")
1071    } else {
1072        format!("{dir}/{source}.swift")
1073    };
1074    if ctx.file_exists(&candidate) {
1075        return (Some(candidate), false);
1076    }
1077    (None, false)
1078}
1079
1080// ---------------------------------------------------------------------------
1081// Scala
1082// ---------------------------------------------------------------------------
1083
1084pub fn resolve_scala(imp: &ImportInfo, ctx: &ResolverContext) -> (Option<String>, bool) {
1085    let source = &imp.source;
1086
1087    if source.starts_with("scala.") || source.starts_with("java.") {
1088        return (None, true);
1089    }
1090
1091    let parts: Vec<&str> = source.rsplitn(2, '.').collect();
1092    if parts.len() < 2 {
1093        return (None, true);
1094    }
1095
1096    let name = parts[0];
1097    let package_path = parts[1].replace('.', "/");
1098
1099    for ext in ["scala", "sc"] {
1100        let candidate = format!("{package_path}/{name}.{ext}");
1101        if ctx.file_exists(&candidate) {
1102            return (Some(candidate), false);
1103        }
1104    }
1105    (None, false)
1106}
1107
1108// ---------------------------------------------------------------------------
1109// Elixir
1110// ---------------------------------------------------------------------------
1111
1112pub fn resolve_elixir(
1113    imp: &ImportInfo,
1114    file_path: &str,
1115    ctx: &ResolverContext,
1116) -> (Option<String>, bool) {
1117    let source = &imp.source;
1118
1119    if source.starts_with("Kernel") || source.starts_with("Enum") || source.starts_with("IO") {
1120        return (None, true);
1121    }
1122
1123    let snake =
1124        source
1125            .replace('.', "/")
1126            .chars()
1127            .enumerate()
1128            .fold(String::new(), |mut acc, (i, c)| {
1129                if c.is_uppercase() && i > 0 && !acc.ends_with('/') {
1130                    acc.push('_');
1131                }
1132                acc.push(c.to_ascii_lowercase());
1133                acc
1134            });
1135
1136    let dir = Path::new(file_path)
1137        .parent()
1138        .map(|p| p.to_string_lossy().to_string())
1139        .unwrap_or_default();
1140
1141    for ext in ["ex", "exs"] {
1142        let candidate = format!("lib/{snake}.{ext}");
1143        if ctx.file_exists(&candidate) {
1144            return (Some(candidate), false);
1145        }
1146        if !dir.is_empty() {
1147            let local = format!("{dir}/{snake}.{ext}");
1148            if ctx.file_exists(&local) {
1149                return (Some(local), false);
1150            }
1151        }
1152    }
1153    (None, false)
1154}