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#[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 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 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 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 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 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 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#[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
325fn 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 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}