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