1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use fallow_config::ResolvedConfig;
5use oxc_resolver::{ResolveOptions, Resolver};
6use rayon::prelude::*;
7
8use crate::discover::{DiscoveredFile, FileId};
9use crate::extract::{ImportInfo, ModuleInfo, ReExportInfo};
10
11#[derive(Debug, Clone)]
13pub enum ResolveResult {
14 InternalModule(FileId),
16 ExternalFile(PathBuf),
18 NpmPackage(String),
20 Unresolvable(String),
22}
23
24#[derive(Debug, Clone)]
26pub struct ResolvedImport {
27 pub info: ImportInfo,
28 pub target: ResolveResult,
29}
30
31#[derive(Debug, Clone)]
33pub struct ResolvedReExport {
34 pub info: ReExportInfo,
35 pub target: ResolveResult,
36}
37
38#[derive(Debug)]
40pub struct ResolvedModule {
41 pub file_id: FileId,
42 pub path: PathBuf,
43 pub exports: Vec<crate::extract::ExportInfo>,
44 pub re_exports: Vec<ResolvedReExport>,
45 pub resolved_imports: Vec<ResolvedImport>,
46 pub resolved_dynamic_imports: Vec<ResolvedImport>,
47 pub resolved_dynamic_patterns: Vec<(crate::extract::DynamicImportPattern, Vec<FileId>)>,
48 pub member_accesses: Vec<crate::extract::MemberAccess>,
49 pub whole_object_uses: Vec<String>,
50 pub has_cjs_exports: bool,
51}
52
53pub fn resolve_all_imports(
55 modules: &[ModuleInfo],
56 config: &ResolvedConfig,
57 files: &[DiscoveredFile],
58) -> Vec<ResolvedModule> {
59 let path_to_id: HashMap<PathBuf, FileId> = files
61 .iter()
62 .filter_map(|f| {
63 f.path
64 .canonicalize()
65 .ok()
66 .map(|canonical| (canonical, f.id))
67 })
68 .collect();
69
70 let file_id_to_path: HashMap<FileId, PathBuf> =
71 files.iter().map(|f| (f.id, f.path.clone())).collect();
72
73 let resolver = create_resolver(config);
75
76 modules
78 .par_iter()
79 .filter_map(|module| {
80 let file_path = match file_id_to_path.get(&module.file_id) {
81 Some(p) => p,
82 None => {
83 tracing::warn!(
84 file_id = module.file_id.0,
85 "Skipping module with unknown file_id during resolution"
86 );
87 return None;
88 }
89 };
90
91 let resolved_imports: Vec<ResolvedImport> = module
92 .imports
93 .iter()
94 .map(|imp| ResolvedImport {
95 info: imp.clone(),
96 target: resolve_specifier(&resolver, file_path, &imp.source, &path_to_id),
97 })
98 .collect();
99
100 let resolved_dynamic_imports: Vec<ResolvedImport> = module
101 .dynamic_imports
102 .iter()
103 .map(|imp| ResolvedImport {
104 info: ImportInfo {
105 source: imp.source.clone(),
106 imported_name: crate::extract::ImportedName::SideEffect,
107 local_name: String::new(),
108 is_type_only: false,
109 span: imp.span,
110 },
111 target: resolve_specifier(&resolver, file_path, &imp.source, &path_to_id),
112 })
113 .collect();
114
115 let re_exports: Vec<ResolvedReExport> = module
116 .re_exports
117 .iter()
118 .map(|re| ResolvedReExport {
119 info: re.clone(),
120 target: resolve_specifier(&resolver, file_path, &re.source, &path_to_id),
121 })
122 .collect();
123
124 let require_imports: Vec<ResolvedImport> = module
127 .require_calls
128 .iter()
129 .flat_map(|req| {
130 let target = resolve_specifier(&resolver, file_path, &req.source, &path_to_id);
131 if req.destructured_names.is_empty() {
132 vec![ResolvedImport {
133 info: ImportInfo {
134 source: req.source.clone(),
135 imported_name: crate::extract::ImportedName::Namespace,
136 local_name: req.local_name.clone().unwrap_or_default(),
137 is_type_only: false,
138 span: req.span,
139 },
140 target,
141 }]
142 } else {
143 req.destructured_names
144 .iter()
145 .map(|name| ResolvedImport {
146 info: ImportInfo {
147 source: req.source.clone(),
148 imported_name: crate::extract::ImportedName::Named(
149 name.clone(),
150 ),
151 local_name: name.clone(),
152 is_type_only: false,
153 span: req.span,
154 },
155 target: target.clone(),
156 })
157 .collect()
158 }
159 })
160 .collect();
161
162 let mut all_imports = resolved_imports;
163 all_imports.extend(require_imports);
164
165 let from_dir = file_path
169 .parent()
170 .unwrap_or(file_path)
171 .canonicalize()
172 .unwrap_or_else(|_| file_path.parent().unwrap_or(file_path).to_path_buf());
173 let resolved_dynamic_patterns: Vec<(
174 crate::extract::DynamicImportPattern,
175 Vec<FileId>,
176 )> = module
177 .dynamic_import_patterns
178 .iter()
179 .filter_map(|pattern| {
180 let glob_str = make_glob_from_pattern(pattern);
181 let matcher = globset::Glob::new(&glob_str)
182 .ok()
183 .map(|g| g.compile_matcher())?;
184 let matched: Vec<FileId> = files
185 .iter()
186 .filter(|f| {
187 let canonical = f.path.canonicalize().unwrap_or(f.path.clone());
189 if let Ok(relative) = canonical.strip_prefix(&from_dir) {
190 let rel_str = format!("./{}", relative.to_string_lossy());
191 matcher.is_match(&rel_str)
192 } else {
193 false
194 }
195 })
196 .map(|f| f.id)
197 .collect();
198 if matched.is_empty() {
199 None
200 } else {
201 Some((pattern.clone(), matched))
202 }
203 })
204 .collect();
205
206 Some(ResolvedModule {
207 file_id: module.file_id,
208 path: file_path.clone(),
209 exports: module.exports.clone(),
210 re_exports,
211 resolved_imports: all_imports,
212 resolved_dynamic_imports,
213 resolved_dynamic_patterns,
214 member_accesses: module.member_accesses.clone(),
215 whole_object_uses: module.whole_object_uses.clone(),
216 has_cjs_exports: module.has_cjs_exports,
217 })
218 })
219 .collect()
220}
221
222fn create_resolver(config: &ResolvedConfig) -> Resolver {
224 let mut options = ResolveOptions {
225 extensions: vec![
226 ".ts".into(),
227 ".tsx".into(),
228 ".d.ts".into(),
229 ".d.mts".into(),
230 ".d.cts".into(),
231 ".mts".into(),
232 ".cts".into(),
233 ".js".into(),
234 ".jsx".into(),
235 ".mjs".into(),
236 ".cjs".into(),
237 ".json".into(),
238 ".vue".into(),
239 ".svelte".into(),
240 ],
241 extension_alias: vec![
244 (
245 ".js".into(),
246 vec![".ts".into(), ".tsx".into(), ".js".into()],
247 ),
248 (".jsx".into(), vec![".tsx".into(), ".jsx".into()]),
249 (".mjs".into(), vec![".mts".into(), ".mjs".into()]),
250 (".cjs".into(), vec![".cts".into(), ".cjs".into()]),
251 ],
252 condition_names: vec![
253 "import".into(),
254 "require".into(),
255 "default".into(),
256 "types".into(),
257 "node".into(),
258 ],
259 main_fields: vec!["module".into(), "main".into()],
260 ..Default::default()
261 };
262
263 let tsconfig_candidates = ["tsconfig.json", "tsconfig.app.json", "tsconfig.build.json"];
265 let root_tsconfig = tsconfig_candidates
266 .iter()
267 .map(|name| config.root.join(name))
268 .find(|p| p.exists());
269
270 if let Some(tsconfig) = root_tsconfig {
271 options.tsconfig = Some(oxc_resolver::TsconfigDiscovery::Manual(
273 oxc_resolver::TsconfigOptions {
274 config_file: tsconfig,
275 references: oxc_resolver::TsconfigReferences::Auto,
276 },
277 ));
278 } else {
279 options.tsconfig = Some(oxc_resolver::TsconfigDiscovery::Auto);
283 }
284
285 Resolver::new(options)
286}
287
288fn resolve_specifier(
290 resolver: &Resolver,
291 from_file: &Path,
292 specifier: &str,
293 path_to_id: &HashMap<PathBuf, FileId>,
294) -> ResolveResult {
295 if specifier.contains("://") || specifier.starts_with("data:") {
297 return ResolveResult::ExternalFile(PathBuf::from(specifier));
298 }
299
300 let dir = from_file.parent().unwrap_or(from_file);
301
302 match resolver.resolve(dir, specifier) {
303 Ok(resolved) => {
304 let resolved_path = resolved.path();
305 match resolved_path.canonicalize() {
306 Ok(canonical) => {
307 if let Some(&file_id) = path_to_id.get(&canonical) {
308 ResolveResult::InternalModule(file_id)
309 } else if let Some(pkg_name) =
310 extract_package_name_from_node_modules_path(&canonical)
311 {
312 ResolveResult::NpmPackage(pkg_name)
313 } else {
314 ResolveResult::ExternalFile(canonical)
315 }
316 }
317 Err(_) => {
318 if let Some(pkg_name) =
319 extract_package_name_from_node_modules_path(resolved_path)
320 {
321 ResolveResult::NpmPackage(pkg_name)
322 } else {
323 ResolveResult::ExternalFile(resolved_path.to_path_buf())
324 }
325 }
326 }
327 }
328 Err(_) => {
329 if is_bare_specifier(specifier) {
330 let pkg_name = extract_package_name(specifier);
331 ResolveResult::NpmPackage(pkg_name)
332 } else {
333 ResolveResult::Unresolvable(specifier.to_string())
334 }
335 }
336 }
337}
338
339fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
345 let components: Vec<&str> = path
346 .components()
347 .filter_map(|c| match c {
348 std::path::Component::Normal(s) => s.to_str(),
349 _ => None,
350 })
351 .collect();
352
353 let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
355
356 let after = &components[nm_idx + 1..];
357 if after.is_empty() {
358 return None;
359 }
360
361 if after[0].starts_with('@') {
362 if after.len() >= 2 {
364 Some(format!("{}/{}", after[0], after[1]))
365 } else {
366 Some(after[0].to_string())
367 }
368 } else {
369 Some(after[0].to_string())
370 }
371}
372
373fn make_glob_from_pattern(pattern: &crate::extract::DynamicImportPattern) -> String {
375 if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
377 return pattern.prefix.clone();
378 }
379 match &pattern.suffix {
380 Some(suffix) => format!("{}*{}", pattern.prefix, suffix),
381 None => format!("{}*", pattern.prefix),
382 }
383}
384
385fn is_bare_specifier(specifier: &str) -> bool {
387 !specifier.starts_with('.')
388 && !specifier.starts_with('/')
389 && !specifier.contains("://")
390 && !specifier.starts_with("data:")
391}
392
393pub fn extract_package_name(specifier: &str) -> String {
397 if specifier.starts_with('@') {
398 let parts: Vec<&str> = specifier.splitn(3, '/').collect();
399 if parts.len() >= 2 {
400 format!("{}/{}", parts[0], parts[1])
401 } else {
402 specifier.to_string()
403 }
404 } else {
405 specifier.split('/').next().unwrap_or(specifier).to_string()
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn test_extract_package_name() {
415 assert_eq!(extract_package_name("react"), "react");
416 assert_eq!(extract_package_name("lodash/merge"), "lodash");
417 assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
418 assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
419 }
420
421 #[test]
422 fn test_is_bare_specifier() {
423 assert!(is_bare_specifier("react"));
424 assert!(is_bare_specifier("@scope/pkg"));
425 assert!(is_bare_specifier("#internal/module"));
426 assert!(!is_bare_specifier("./utils"));
427 assert!(!is_bare_specifier("../lib"));
428 assert!(!is_bare_specifier("/absolute"));
429 }
430
431 #[test]
432 fn test_extract_package_name_from_node_modules_path_regular() {
433 let path = PathBuf::from("/project/node_modules/react/index.js");
434 assert_eq!(
435 extract_package_name_from_node_modules_path(&path),
436 Some("react".to_string())
437 );
438 }
439
440 #[test]
441 fn test_extract_package_name_from_node_modules_path_scoped() {
442 let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
443 assert_eq!(
444 extract_package_name_from_node_modules_path(&path),
445 Some("@babel/core".to_string())
446 );
447 }
448
449 #[test]
450 fn test_extract_package_name_from_node_modules_path_nested() {
451 let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
453 assert_eq!(
454 extract_package_name_from_node_modules_path(&path),
455 Some("pkg-b".to_string())
456 );
457 }
458
459 #[test]
460 fn test_extract_package_name_from_node_modules_path_deep_subpath() {
461 let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
462 assert_eq!(
463 extract_package_name_from_node_modules_path(&path),
464 Some("react-dom".to_string())
465 );
466 }
467
468 #[test]
469 fn test_extract_package_name_from_node_modules_path_no_node_modules() {
470 let path = PathBuf::from("/project/src/components/Button.tsx");
471 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
472 }
473
474 #[test]
475 fn test_extract_package_name_from_node_modules_path_just_node_modules() {
476 let path = PathBuf::from("/project/node_modules");
477 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
478 }
479
480 #[test]
481 fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
482 let path = PathBuf::from("/project/node_modules/@scope");
484 assert_eq!(
485 extract_package_name_from_node_modules_path(&path),
486 Some("@scope".to_string())
487 );
488 }
489
490 #[test]
491 fn test_resolve_specifier_node_modules_returns_npm_package() {
492 let path =
498 PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
499 assert_eq!(
500 extract_package_name_from_node_modules_path(&path),
501 Some("styled-components".to_string())
502 );
503
504 let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
505 assert_eq!(
506 extract_package_name_from_node_modules_path(&path),
507 Some("next".to_string())
508 );
509 }
510}