Skip to main content

blast_radius/resolve/
mod.rs

1use std::collections::{BTreeMap, HashSet};
2use std::path::{Component, Path, PathBuf};
3
4use anyhow::Result;
5
6use crate::fs::{RepoContext, TsConfigPath};
7
8mod package;
9use package::load_package_info;
10pub(crate) use package::{
11    PackageInfo, package_specifier_parts, resolve_package_export, resolve_package_import,
12};
13
14#[cfg(test)]
15mod tests;
16
17/// Shared resolution state and primitives, borrowed by language adapters. Holds
18/// the source-file index, workspace packages, tsconfig aliases, and the
19/// language-specific suffix/package indexes used by Ruby and Java.
20#[derive(Debug, Clone)]
21pub struct ResolveCtx {
22    pub(crate) repo_root: PathBuf,
23    pub(crate) source_files: HashSet<PathBuf>,
24    #[cfg(any(feature = "ruby", feature = "java"))]
25    pub(crate) suffix_index: BTreeMap<PathBuf, PathBuf>,
26    #[cfg(any(feature = "ruby", feature = "java"))]
27    suffix_ambiguities: Vec<SuffixAmbiguity>,
28    #[cfg(feature = "java")]
29    pub(crate) java_package_index: BTreeMap<PathBuf, Vec<PathBuf>>,
30    pub(crate) packages: Vec<PackageInfo>,
31    pub(crate) package_by_name: BTreeMap<String, usize>,
32    pub(crate) tsconfigs: Vec<TsConfigPath>,
33}
34
35#[derive(Debug, Clone)]
36pub enum Resolution {
37    Resolved(PathBuf),
38    Unresolved,
39}
40
41#[cfg(any(feature = "ruby", feature = "java"))]
42#[derive(Debug, Clone)]
43struct SuffixAmbiguity {
44    suffix: PathBuf,
45    paths: Vec<PathBuf>,
46}
47
48#[cfg(any(feature = "ruby", feature = "java"))]
49#[derive(Debug, Clone)]
50struct SuffixIndex {
51    index: BTreeMap<PathBuf, PathBuf>,
52    ambiguities: Vec<SuffixAmbiguity>,
53}
54
55impl ResolveCtx {
56    fn new(context: &RepoContext) -> Result<Self> {
57        let mut packages = Vec::new();
58        for package_json in &context.package_jsons {
59            if let Some(package) = load_package_info(package_json)? {
60                packages.push(package);
61            }
62        }
63        let mut package_by_name = BTreeMap::new();
64        for (index, package) in packages.iter().enumerate() {
65            package_by_name.entry(package.name.clone()).or_insert(index);
66        }
67        #[cfg(any(feature = "ruby", feature = "java"))]
68        let suffix_index = build_suffix_index(&context.repo_root, &context.source_files);
69
70        Ok(Self {
71            repo_root: context.repo_root.clone(),
72            source_files: context.source_files.iter().cloned().collect(),
73            #[cfg(any(feature = "ruby", feature = "java"))]
74            suffix_index: suffix_index.index,
75            #[cfg(any(feature = "ruby", feature = "java"))]
76            suffix_ambiguities: suffix_index.ambiguities,
77            #[cfg(feature = "java")]
78            java_package_index: build_java_package_index(&context.repo_root, &context.source_files),
79            packages,
80            package_by_name,
81            tsconfigs: context.tsconfigs.clone(),
82        })
83    }
84
85    fn normalize_importer(&self, importer: &Path) -> PathBuf {
86        let cleaned = clean_path(importer);
87        if self.source_files.contains(&cleaned) {
88            return cleaned;
89        }
90
91        importer.canonicalize().unwrap_or(cleaned)
92    }
93
94    /// Resolve a relative or absolute path specifier against `base`, probing the
95    /// given `extensions`. The caller passes only its own language family's
96    /// extensions so resolution never crosses language boundaries.
97    pub(crate) fn resolve_path(
98        &self,
99        base: &Path,
100        specifier: &str,
101        extensions: &[&str],
102    ) -> Resolution {
103        let path = if specifier.starts_with('/') {
104            clean_path(&self.repo_root.join(specifier.trim_start_matches('/')))
105        } else {
106            clean_path(&base.join(specifier))
107        };
108
109        self.try_resolve_candidate(&path, extensions)
110            .map(Resolution::Resolved)
111            .unwrap_or(Resolution::Unresolved)
112    }
113
114    /// Map a path candidate to a concrete source file, trying exact match, then
115    /// the given `extensions`, then `index.*` directory entrypoints. Probing is
116    /// scoped to the caller's extensions, so e.g. a JS import cannot resolve to
117    /// a `.py` file. Language-specific directory entrypoints (Python's
118    /// `__init__.py`) are handled by the owning adapter.
119    pub(crate) fn try_resolve_candidate(
120        &self,
121        candidate: &Path,
122        extensions: &[&str],
123    ) -> Option<PathBuf> {
124        let candidate = clean_path(candidate);
125
126        // An exact hit still has to be in the caller's language family — a `.py`
127        // file must not satisfy a JS `import "./model.py"` even though it exists.
128        if self.source_files.contains(&candidate) {
129            let cross_language = candidate
130                .extension()
131                .and_then(|ext| ext.to_str())
132                .is_some_and(|ext| !extensions.contains(&ext));
133            if !cross_language {
134                return Some(candidate);
135            }
136        }
137
138        let candidate_ext = candidate.extension().and_then(|ext| ext.to_str());
139
140        if candidate_ext.is_none() {
141            for extension in extensions {
142                let path = candidate.with_extension(extension);
143                if self.source_files.contains(&path) {
144                    return Some(path);
145                }
146            }
147        }
148
149        if let Some(ext) = candidate_ext {
150            // A multi-dot basename ('./recipe.types' -> recipe.types.ts) wins
151            // over extension rewriting, so probe the appended form first.
152            if !extensions.contains(&ext) {
153                for extension in extensions {
154                    let path = candidate.with_extension(format!("{ext}.{extension}"));
155                    if self.source_files.contains(&path) {
156                        return Some(path);
157                    }
158                }
159            }
160
161            // Extension replacement only applies to JS-emitted specifiers that
162            // TS rewrites to their source counterparts; './theme.css' stays an
163            // unresolved asset rather than hitting theme.ts.
164            if extensions.contains(&"ts") {
165                for replacement in ts_counterparts(ext) {
166                    let path = candidate.with_extension(replacement);
167                    if self.source_files.contains(&path) {
168                        return Some(path);
169                    }
170                }
171            }
172        }
173
174        // Declaration-only modules: './types' backed by types.d.ts.
175        if extensions.contains(&"ts") {
176            let mut appended = candidate.clone().into_os_string();
177            appended.push(".d.ts");
178            let path = PathBuf::from(appended);
179            if self.source_files.contains(&path) {
180                return Some(path);
181            }
182        }
183
184        if candidate_ext.is_none() {
185            for extension in extensions {
186                let path = candidate.join(format!("index.{extension}"));
187                if self.source_files.contains(&path) {
188                    return Some(path);
189                }
190            }
191            if extensions.contains(&"ts") {
192                let path = candidate.join("index.d.ts");
193                if self.source_files.contains(&path) {
194                    return Some(path);
195                }
196            }
197        }
198
199        None
200    }
201}
202
203/// Resolves import specifiers to repo source files by dispatching to the
204/// language adapter that owns the importing file.
205#[derive(Debug, Clone)]
206pub struct Resolver {
207    ctx: ResolveCtx,
208}
209
210impl Resolver {
211    pub fn new(context: &RepoContext) -> Result<Self> {
212        Ok(Self {
213            ctx: ResolveCtx::new(context)?,
214        })
215    }
216
217    pub fn warnings(&self) -> Vec<String> {
218        #[cfg(any(feature = "ruby", feature = "java"))]
219        {
220            let mut warnings = Vec::new();
221            for ambiguity in &self.ctx.suffix_ambiguities {
222                let paths = ambiguity
223                    .paths
224                    .iter()
225                    .map(|path| path.strip_prefix(&self.ctx.repo_root).unwrap_or(path))
226                    .map(|path| path.display().to_string())
227                    .collect::<Vec<_>>()
228                    .join(", ");
229                warnings.push(format!(
230                    "ambiguous suffix resolution for {} matched multiple files: {paths}",
231                    ambiguity.suffix.display()
232                ));
233            }
234            warnings
235        }
236
237        #[cfg(not(any(feature = "ruby", feature = "java")))]
238        {
239            Vec::new()
240        }
241    }
242
243    pub fn resolve(&self, importer: &Path, specifier: &str) -> Resolution {
244        let importer = self.ctx.normalize_importer(importer);
245        crate::language::adapter_for(&importer).resolve(&self.ctx, &importer, specifier)
246    }
247
248    pub fn is_internal_specifier(&self, importer: &Path, specifier: &str) -> bool {
249        let importer = self.ctx.normalize_importer(importer);
250        crate::language::adapter_for(&importer).is_internal(&self.ctx, &importer, specifier)
251    }
252}
253
254#[cfg(any(feature = "ruby", feature = "java"))]
255fn build_suffix_index(repo_root: &Path, source_files: &[PathBuf]) -> SuffixIndex {
256    let mut all_matches: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
257
258    for file in source_files {
259        let Some(ext) = file.extension().and_then(|ext| ext.to_str()) else {
260            continue;
261        };
262        if !matches!(ext, "rb" | "java") {
263            continue;
264        }
265
266        let relative = file.strip_prefix(repo_root).unwrap_or(file);
267        for suffix in path_suffixes(relative) {
268            all_matches.entry(suffix).or_default().push(file.clone());
269        }
270    }
271
272    let mut index = BTreeMap::new();
273    let mut ambiguities = Vec::new();
274    for (suffix, paths) in all_matches {
275        if let Some(first) = paths.first() {
276            index.insert(suffix.clone(), first.clone());
277        }
278        if paths.len() > 1 {
279            ambiguities.push(SuffixAmbiguity { suffix, paths });
280        }
281    }
282
283    SuffixIndex { index, ambiguities }
284}
285
286#[cfg(feature = "java")]
287fn build_java_package_index(
288    repo_root: &Path,
289    source_files: &[PathBuf],
290) -> BTreeMap<PathBuf, Vec<PathBuf>> {
291    let mut index: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
292
293    for file in source_files {
294        if file.extension().and_then(|ext| ext.to_str()) != Some("java") {
295            continue;
296        }
297        let Some(parent) = file.strip_prefix(repo_root).unwrap_or(file).parent() else {
298            continue;
299        };
300
301        for suffix in path_suffixes(parent) {
302            index.entry(suffix).or_default().push(file.clone());
303        }
304    }
305
306    index
307}
308
309#[cfg(any(feature = "ruby", feature = "java"))]
310fn path_suffixes(path: &Path) -> Vec<PathBuf> {
311    let components: Vec<_> = path.iter().collect();
312    let mut suffixes = Vec::new();
313
314    for start in 0..components.len() {
315        let mut suffix = PathBuf::new();
316        for component in &components[start..] {
317            suffix.push(Path::new(*component));
318        }
319        suffixes.push(suffix);
320    }
321
322    suffixes
323}
324
325/// TS source extensions a JS-emitted specifier extension may map back to,
326/// per TypeScript module resolution.
327fn ts_counterparts(ext: &str) -> &'static [&'static str] {
328    match ext {
329        "js" => &["ts", "tsx", "d.ts"],
330        "jsx" => &["tsx"],
331        "mjs" => &["mts", "d.mts"],
332        "cjs" => &["cts", "d.cts"],
333        _ => &[],
334    }
335}
336
337pub(crate) fn match_alias(pattern: &str, specifier: &str) -> Option<Vec<String>> {
338    if let Some((prefix, suffix)) = pattern.split_once('*') {
339        // Prefix and suffix must not overlap in the specifier ("lib/*/lib"
340        // cannot match "lib/lib").
341        if specifier.len() >= prefix.len() + suffix.len()
342            && specifier.starts_with(prefix)
343            && specifier.ends_with(suffix)
344        {
345            let middle = &specifier[prefix.len()..specifier.len() - suffix.len()];
346            return Some(vec![middle.to_string()]);
347        }
348        return None;
349    }
350
351    if pattern == specifier {
352        Some(Vec::new())
353    } else {
354        None
355    }
356}
357
358pub(crate) fn apply_alias_target(target: &str, captures: &[String]) -> String {
359    let mut resolved = target.to_string();
360    for capture in captures {
361        if let Some(index) = resolved.find('*') {
362            resolved.replace_range(index..=index, capture);
363        }
364    }
365    resolved
366}
367
368pub(crate) fn clean_path(path: &Path) -> PathBuf {
369    let mut result = PathBuf::new();
370
371    for component in path.components() {
372        match component {
373            Component::CurDir => {}
374            Component::ParentDir => {
375                result.pop();
376            }
377            other => result.push(other.as_os_str()),
378        }
379    }
380
381    result
382}