blast_radius/resolve/
mod.rs1use 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#[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 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 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 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 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 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 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#[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
198fn 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 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}