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, and tsconfig aliases.
19#[derive(Debug, Clone)]
20pub struct ResolveCtx {
21    pub(crate) repo_root: PathBuf,
22    pub(crate) source_files: HashSet<PathBuf>,
23    pub(crate) packages: Vec<PackageInfo>,
24    pub(crate) package_by_name: BTreeMap<String, usize>,
25    pub(crate) tsconfigs: Vec<TsConfigPath>,
26}
27
28#[derive(Debug, Clone)]
29pub enum Resolution {
30    Resolved(PathBuf),
31    Unresolved,
32}
33
34impl ResolveCtx {
35    fn new(context: &RepoContext) -> Result<Self> {
36        let mut packages = Vec::new();
37        for package_json in &context.package_jsons {
38            if let Some(package) = load_package_info(package_json)? {
39                packages.push(package);
40            }
41        }
42        let mut package_by_name = BTreeMap::new();
43        for (index, package) in packages.iter().enumerate() {
44            package_by_name.entry(package.name.clone()).or_insert(index);
45        }
46        Ok(Self {
47            repo_root: context.repo_root.clone(),
48            source_files: context.source_files.iter().cloned().collect(),
49            packages,
50            package_by_name,
51            tsconfigs: context.tsconfigs.clone(),
52        })
53    }
54
55    fn normalize_importer(&self, importer: &Path) -> PathBuf {
56        let cleaned = clean_path(importer);
57        if self.source_files.contains(&cleaned) {
58            return cleaned;
59        }
60
61        importer.canonicalize().unwrap_or(cleaned)
62    }
63
64    /// Resolve a relative or absolute path specifier against `base`, probing the
65    /// given `extensions`. The caller passes only its own language family's
66    /// extensions so resolution never crosses language boundaries.
67    pub(crate) fn resolve_path(
68        &self,
69        base: &Path,
70        specifier: &str,
71        extensions: &[&str],
72    ) -> Resolution {
73        let path = if specifier.starts_with('/') {
74            clean_path(&self.repo_root.join(specifier.trim_start_matches('/')))
75        } else {
76            clean_path(&base.join(specifier))
77        };
78
79        self.try_resolve_candidate(&path, extensions)
80            .map(Resolution::Resolved)
81            .unwrap_or(Resolution::Unresolved)
82    }
83
84    /// Map a path candidate to a concrete source file, trying exact match, then
85    /// the given `extensions`, then `index.*` directory entrypoints. Probing is
86    /// scoped to the caller's extensions, so e.g. a JS import cannot resolve to
87    /// a `.py` file. Language-specific directory entrypoints (Python's
88    /// `__init__.py`) are handled by the owning adapter.
89    pub(crate) fn try_resolve_candidate(
90        &self,
91        candidate: &Path,
92        extensions: &[&str],
93    ) -> Option<PathBuf> {
94        let candidate = clean_path(candidate);
95
96        // An exact hit still has to be in the caller's language family — a `.py`
97        // file must not satisfy a JS `import "./model.py"` even though it exists.
98        if self.source_files.contains(&candidate) {
99            let cross_language = candidate
100                .extension()
101                .and_then(|ext| ext.to_str())
102                .is_some_and(|ext| !extensions.contains(&ext));
103            if !cross_language {
104                return Some(candidate);
105            }
106        }
107
108        let candidate_ext = candidate.extension().and_then(|ext| ext.to_str());
109
110        if candidate_ext.is_none() {
111            for extension in extensions {
112                let path = candidate.with_extension(extension);
113                if self.source_files.contains(&path) {
114                    return Some(path);
115                }
116            }
117        }
118
119        if let Some(ext) = candidate_ext {
120            // A multi-dot basename ('./recipe.types' -> recipe.types.ts) wins
121            // over extension rewriting, so probe the appended form first.
122            if !extensions.contains(&ext) {
123                for extension in extensions {
124                    let path = candidate.with_extension(format!("{ext}.{extension}"));
125                    if self.source_files.contains(&path) {
126                        return Some(path);
127                    }
128                }
129            }
130
131            // Extension replacement only applies to JS-emitted specifiers that
132            // TS rewrites to their source counterparts; './theme.css' stays an
133            // unresolved asset rather than hitting theme.ts.
134            if extensions.contains(&"ts") {
135                for replacement in ts_counterparts(ext) {
136                    let path = candidate.with_extension(replacement);
137                    if self.source_files.contains(&path) {
138                        return Some(path);
139                    }
140                }
141            }
142        }
143
144        // Declaration-only modules: './types' backed by types.d.ts.
145        if extensions.contains(&"ts") {
146            let mut appended = candidate.clone().into_os_string();
147            appended.push(".d.ts");
148            let path = PathBuf::from(appended);
149            if self.source_files.contains(&path) {
150                return Some(path);
151            }
152        }
153
154        if candidate_ext.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            if extensions.contains(&"ts") {
162                let path = candidate.join("index.d.ts");
163                if self.source_files.contains(&path) {
164                    return Some(path);
165                }
166            }
167        }
168
169        None
170    }
171}
172
173/// Resolves import specifiers to repo source files by dispatching to the
174/// language adapter that owns the importing file.
175#[derive(Debug, Clone)]
176pub struct Resolver {
177    ctx: ResolveCtx,
178}
179
180impl Resolver {
181    pub fn new(context: &RepoContext) -> Result<Self> {
182        Ok(Self {
183            ctx: ResolveCtx::new(context)?,
184        })
185    }
186
187    pub fn resolve(&self, importer: &Path, specifier: &str) -> Resolution {
188        let importer = self.ctx.normalize_importer(importer);
189        crate::language::adapter_for(&importer).resolve(&self.ctx, &importer, specifier)
190    }
191
192    pub fn is_internal_specifier(&self, importer: &Path, specifier: &str) -> bool {
193        let importer = self.ctx.normalize_importer(importer);
194        crate::language::adapter_for(&importer).is_internal(&self.ctx, &importer, specifier)
195    }
196}
197
198/// TS source extensions a JS-emitted specifier extension may map back to,
199/// per TypeScript module resolution.
200fn ts_counterparts(ext: &str) -> &'static [&'static str] {
201    match ext {
202        "js" => &["ts", "tsx", "d.ts"],
203        "jsx" => &["tsx"],
204        "mjs" => &["mts", "d.mts"],
205        "cjs" => &["cts", "d.cts"],
206        _ => &[],
207    }
208}
209
210pub(crate) fn match_alias(pattern: &str, specifier: &str) -> Option<Vec<String>> {
211    if let Some((prefix, suffix)) = pattern.split_once('*') {
212        // Prefix and suffix must not overlap in the specifier ("lib/*/lib"
213        // cannot match "lib/lib").
214        if specifier.len() >= prefix.len() + suffix.len()
215            && specifier.starts_with(prefix)
216            && specifier.ends_with(suffix)
217        {
218            let middle = &specifier[prefix.len()..specifier.len() - suffix.len()];
219            return Some(vec![middle.to_string()]);
220        }
221        return None;
222    }
223
224    if pattern == specifier {
225        Some(Vec::new())
226    } else {
227        None
228    }
229}
230
231pub(crate) fn apply_alias_target(target: &str, captures: &[String]) -> String {
232    let mut resolved = target.to_string();
233    for capture in captures {
234        if let Some(index) = resolved.find('*') {
235            resolved.replace_range(index..=index, capture);
236        }
237    }
238    resolved
239}
240
241pub(crate) fn clean_path(path: &Path) -> PathBuf {
242    let mut result = PathBuf::new();
243
244    for component in path.components() {
245        match component {
246            Component::CurDir => {}
247            Component::ParentDir => {
248                result.pop();
249            }
250            other => result.push(other.as_os_str()),
251        }
252    }
253
254    result
255}