Skip to main content

lean_ctx_syntax/import_resolver/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Import-to-file resolution (AST-driven import strings → project paths).
4//!
5//! Resolves import strings from `deep_queries::ImportInfo` to actual file paths
6//! within a project. Handles language-specific module systems:
7//! - TypeScript/JavaScript: relative paths, index files, package.json, tsconfig paths
8//! - Python: dotted modules, __init__.py, relative imports
9//! - Rust: crate/super/self resolution, mod.rs
10//! - Go: go.mod module path, package = directory
11//! - Java: package-to-directory mapping
12//! - C/C++: local includes (best-effort)
13//! - Ruby: require_relative (best-effort)
14//! - PHP: include/require (best-effort)
15//! - Bash: source/. (best-effort)
16//! - Dart: relative + `package:<name>/` (best-effort)
17//! - Zig: @import("path.zig") (best-effort)
18
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22use super::deep_queries::ImportInfo;
23
24#[derive(Debug, Clone)]
25pub struct ResolvedImport {
26    pub source: String,
27    pub resolved_path: Option<String>,
28    pub is_external: bool,
29    pub line: usize,
30}
31
32#[derive(Debug)]
33pub struct ResolverContext {
34    pub project_root: PathBuf,
35    pub file_paths: Vec<String>,
36    pub tsconfig_paths: HashMap<String, String>,
37    pub go_module: Option<String>,
38    pub dart_package: Option<String>,
39    file_set: std::collections::HashSet<String>,
40    /// Namespace path (`A/B/C`) -> representative `.cs` file, for C# `using`
41    /// resolution. Keyed by *declared* `namespace` first (authoritative) and by
42    /// folder suffix as a fallback (see `build_csharp_namespace_index`).
43    csharp_ns_index: HashMap<String, String>,
44}
45
46impl ResolverContext {
47    /// `file_contents` is an optional in-memory cache (relative path -> source).
48    /// It is used to read declared C# namespaces without touching disk; pass an
49    /// empty map when contents are not available (a bounded head-read from disk
50    /// is the fallback).
51    pub fn new(
52        project_root: &Path,
53        file_paths: Vec<String>,
54        file_contents: &HashMap<String, String>,
55    ) -> Self {
56        let file_set: std::collections::HashSet<String> = file_paths.iter().cloned().collect();
57
58        let tsconfig_paths = load_tsconfig_paths(project_root);
59        let go_module = load_go_module(project_root);
60        let dart_package = load_dart_package(project_root);
61        let csharp_ns_index =
62            build_csharp_namespace_index(project_root, &file_paths, file_contents);
63
64        Self {
65            project_root: project_root.to_path_buf(),
66            file_paths,
67            tsconfig_paths,
68            go_module,
69            dart_package,
70            file_set,
71            csharp_ns_index,
72        }
73    }
74
75    fn file_exists(&self, rel_path: &str) -> bool {
76        self.file_set.contains(rel_path)
77    }
78
79    /// Representative `.cs` file for a namespace path (`A/B/C`), matched as a
80    /// directory suffix so root prefixes (`src/`, project folder) don't break it.
81    fn csharp_namespace_file(&self, namespace_path: &str) -> Option<&str> {
82        self.csharp_ns_index.get(namespace_path).map(String::as_str)
83    }
84}
85
86/// Maps C# namespace paths (`A/B/C`) to a representative `.cs` file so that
87/// `using A.B.C` resolves to a real project file. Two sources, in priority order:
88///
89/// 1. **Declared namespaces** (authoritative): the `namespace A.B.C` written in
90///    each file, read from the in-memory content cache (or a bounded head-read
91///    from disk). This is the only correct source when the namespace does *not*
92///    mirror the folder layout (the common .NET case with a RootNamespace).
93/// 2. **Folder suffixes** (fallback): every trailing directory suffix of each
94///    file, for sources whose namespace we could not read.
95///
96/// Deterministic: the lexicographically smallest file wins for a given key.
97fn build_csharp_namespace_index(
98    project_root: &Path,
99    file_paths: &[String],
100    file_contents: &HashMap<String, String>,
101) -> HashMap<String, String> {
102    let mut cs_files: Vec<&String> = file_paths
103        .iter()
104        .filter(|f| {
105            Path::new(f.as_str())
106                .extension()
107                .and_then(|e| e.to_str())
108                .is_some_and(|e| e.eq_ignore_ascii_case("cs"))
109        })
110        .collect();
111    if cs_files.is_empty() {
112        return HashMap::new();
113    }
114    cs_files.sort();
115
116    let mut map: HashMap<String, String> = HashMap::new();
117
118    // 1) Declared namespaces (authoritative). Content from the cache when present,
119    //    otherwise a bounded head-read from disk. Capped to avoid pathological I/O.
120    const MAX_CS_FILES_READ: usize = 5000;
121    for file in cs_files.iter().take(MAX_CS_FILES_READ) {
122        let content: Option<std::borrow::Cow<'_, str>> = match file_contents.get(*file) {
123            Some(c) => Some(std::borrow::Cow::Borrowed(c.as_str())),
124            None => read_file_head(&project_root.join(file.as_str()), 64 * 1024)
125                .map(std::borrow::Cow::Owned),
126        };
127        let Some(content) = content else { continue };
128        for ns in extract_csharp_namespaces(&content) {
129            let key = ns.replace('.', "/");
130            map.entry(key).or_insert_with(|| (*file).clone());
131        }
132    }
133
134    // 2) Folder-suffix fallback (does not overwrite declared-namespace entries).
135    for file in &cs_files {
136        let dir = Path::new(file.as_str())
137            .parent()
138            .map(|p| p.to_string_lossy().replace('\\', "/"))
139            .unwrap_or_default();
140        let segs: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
141        for start in 0..segs.len() {
142            let key = segs[start..].join("/");
143            map.entry(key).or_insert_with(|| (*file).clone());
144        }
145    }
146    map
147}
148
149/// Extract every `namespace A.B.C` declared in a C# source (block or file-scoped).
150fn extract_csharp_namespaces(content: &str) -> Vec<String> {
151    let mut out: Vec<String> = Vec::new();
152    for line in content.lines() {
153        let Some(rest) = line.trim_start().strip_prefix("namespace ") else {
154            continue;
155        };
156        let name: String = rest
157            .trim_start()
158            .chars()
159            .take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_')
160            .collect();
161        if !name.is_empty() && !out.contains(&name) {
162            out.push(name);
163        }
164    }
165    out
166}
167
168/// Read at most `max_bytes` from the start of a file (namespace declarations are
169/// always near the top), tolerating non-UTF-8 bytes. Returns `None` on error.
170fn read_file_head(path: &Path, max_bytes: usize) -> Option<String> {
171    use std::io::Read;
172    let mut f = std::fs::File::open(path).ok()?;
173    let mut buf = vec![0u8; max_bytes];
174    let n = f.read(&mut buf).ok()?;
175    buf.truncate(n);
176    Some(String::from_utf8_lossy(&buf).into_owned())
177}
178
179pub fn resolve_imports(
180    imports: &[ImportInfo],
181    file_path: &str,
182    ext: &str,
183    ctx: &ResolverContext,
184) -> Vec<ResolvedImport> {
185    imports
186        .iter()
187        .map(|imp| {
188            let (resolved, is_external) = resolve_one(imp, file_path, ext, ctx);
189            ResolvedImport {
190                source: imp.source.clone(),
191                resolved_path: resolved,
192                is_external,
193                line: imp.line,
194            }
195        })
196        .collect()
197}
198
199fn resolve_one(
200    imp: &ImportInfo,
201    file_path: &str,
202    ext: &str,
203    ctx: &ResolverContext,
204) -> (Option<String>, bool) {
205    match ext {
206        "ts" | "tsx" | "js" | "jsx" => resolve_ts(imp, file_path, ctx),
207        "rs" => resolve_rust(imp, file_path, ctx),
208        "py" => resolve_python(imp, file_path, ctx),
209        "go" => resolve_go(imp, ctx),
210        "java" => resolve_java(imp, ctx),
211        "c" | "h" | "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => {
212            resolve_c_like(imp, file_path, ctx)
213        }
214        "rb" => resolve_ruby(imp, file_path, ctx),
215        "php" => resolve_php(imp, file_path, ctx),
216        "sh" | "bash" => resolve_bash(imp, file_path, ctx),
217        "dart" => resolve_dart(imp, file_path, ctx),
218        "zig" => resolve_zig(imp, file_path, ctx),
219        "kt" | "kts" => resolve_kotlin(imp, ctx),
220        "cs" => resolve_csharp(imp, ctx),
221        "swift" => resolve_swift(imp, file_path, ctx),
222        "scala" | "sc" => resolve_scala(imp, ctx),
223        "ex" | "exs" => resolve_elixir(imp, file_path, ctx),
224        // `.tscn` ext_resource paths are `res://` references — identical shape to
225        // a GDScript `preload`, so the GDScript resolver handles them. (#316)
226        "gd" | "tscn" => resolve_gd(imp, file_path, ctx),
227        "lua" | "luau" => resolve_lua(imp, file_path, ctx),
228        _ => (None, true),
229    }
230}
231
232pub mod languages;
233#[allow(clippy::wildcard_imports)]
234use languages::*;
235
236// ---------------------------------------------------------------------------
237// Config Loaders
238// ---------------------------------------------------------------------------
239
240fn load_tsconfig_paths(root: &Path) -> HashMap<String, String> {
241    let mut paths = HashMap::new();
242
243    let candidates = ["tsconfig.json", "tsconfig.base.json", "jsconfig.json"];
244    for name in &candidates {
245        let tsconfig_path = root.join(name);
246        if let Ok(content) = std::fs::read_to_string(&tsconfig_path) {
247            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
248                && let Some(compiler) = json.get("compilerOptions")
249            {
250                let base_url = compiler
251                    .get("baseUrl")
252                    .and_then(|v| v.as_str())
253                    .unwrap_or(".");
254
255                if let Some(path_map) = compiler.get("paths").and_then(|v| v.as_object()) {
256                    for (pattern, targets) in path_map {
257                        if let Some(first_target) = targets
258                            .as_array()
259                            .and_then(|a| a.first())
260                            .and_then(|v| v.as_str())
261                        {
262                            let resolved = if base_url == "." {
263                                first_target.to_string()
264                            } else {
265                                format!("{base_url}/{first_target}")
266                            };
267                            paths.insert(pattern.clone(), resolved);
268                        }
269                    }
270                }
271            }
272            break;
273        }
274    }
275
276    paths
277}
278
279fn load_go_module(root: &Path) -> Option<String> {
280    let go_mod = root.join("go.mod");
281    let content = std::fs::read_to_string(go_mod).ok()?;
282    for line in content.lines() {
283        let trimmed = line.trim();
284        if trimmed.starts_with("module ") {
285            return Some(trimmed.strip_prefix("module ")?.trim().to_string());
286        }
287    }
288    None
289}
290
291fn load_dart_package(root: &Path) -> Option<String> {
292    let pubspec = root.join("pubspec.yaml");
293    let content = std::fs::read_to_string(pubspec).ok()?;
294    for line in content.lines() {
295        let trimmed = line.trim();
296        if let Some(rest) = trimmed.strip_prefix("name:") {
297            let name = rest.trim();
298            if !name.is_empty() {
299                return Some(name.to_string());
300            }
301        }
302    }
303    None
304}
305
306// ---------------------------------------------------------------------------
307// Helpers
308// ---------------------------------------------------------------------------
309
310fn normalize_path(path: &Path) -> String {
311    let mut parts: Vec<&str> = Vec::new();
312    for component in path.components() {
313        match component {
314            std::path::Component::ParentDir => {
315                parts.pop();
316            }
317            std::path::Component::Normal(s) => {
318                parts.push(s.to_str().unwrap_or(""));
319            }
320            _ => {}
321        }
322    }
323    parts.join("/")
324}
325
326#[cfg(test)]
327pub mod tests;