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