1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use dashmap::DashMap;
5use fallow_config::ResolvedConfig;
6use oxc_resolver::{ResolveOptions, Resolver};
7use rayon::prelude::*;
8
9use crate::discover::{DiscoveredFile, FileId};
10use crate::extract::{ImportInfo, ModuleInfo, ReExportInfo};
11
12struct BareSpecifierCache {
18 cache: DashMap<String, ResolveResult>,
19}
20
21impl BareSpecifierCache {
22 fn new() -> Self {
23 Self {
24 cache: DashMap::new(),
25 }
26 }
27
28 fn get(&self, specifier: &str) -> Option<ResolveResult> {
29 self.cache.get(specifier).map(|entry| entry.clone())
30 }
31
32 fn insert(&self, specifier: String, result: ResolveResult) {
33 self.cache.insert(specifier, result);
34 }
35}
36
37#[derive(Debug, Clone)]
39pub enum ResolveResult {
40 InternalModule(FileId),
42 ExternalFile(PathBuf),
44 NpmPackage(String),
46 Unresolvable(String),
48}
49
50#[derive(Debug, Clone)]
52pub struct ResolvedImport {
53 pub info: ImportInfo,
54 pub target: ResolveResult,
55}
56
57#[derive(Debug, Clone)]
59pub struct ResolvedReExport {
60 pub info: ReExportInfo,
61 pub target: ResolveResult,
62}
63
64#[derive(Debug)]
66pub struct ResolvedModule {
67 pub file_id: FileId,
68 pub path: PathBuf,
69 pub exports: Vec<crate::extract::ExportInfo>,
70 pub re_exports: Vec<ResolvedReExport>,
71 pub resolved_imports: Vec<ResolvedImport>,
72 pub resolved_dynamic_imports: Vec<ResolvedImport>,
73 pub resolved_dynamic_patterns: Vec<(crate::extract::DynamicImportPattern, Vec<FileId>)>,
74 pub member_accesses: Vec<crate::extract::MemberAccess>,
75 pub whole_object_uses: Vec<String>,
76 pub has_cjs_exports: bool,
77}
78
79pub fn resolve_all_imports(
81 modules: &[ModuleInfo],
82 config: &ResolvedConfig,
83 files: &[DiscoveredFile],
84) -> Vec<ResolvedModule> {
85 let canonical_paths: Vec<PathBuf> = files
87 .iter()
88 .map(|f| f.path.canonicalize().unwrap_or_else(|_| f.path.clone()))
89 .collect();
90
91 let path_to_id: HashMap<&Path, FileId> = canonical_paths
93 .iter()
94 .enumerate()
95 .map(|(idx, canonical)| (canonical.as_path(), files[idx].id))
96 .collect();
97
98 let raw_path_to_id: HashMap<&Path, FileId> =
100 files.iter().map(|f| (f.path.as_path(), f.id)).collect();
101
102 let file_id_to_path: HashMap<FileId, &Path> =
103 files.iter().map(|f| (f.id, f.path.as_path())).collect();
104
105 let resolver = create_resolver(config);
107
108 let bare_cache = BareSpecifierCache::new();
110
111 modules
113 .par_iter()
114 .filter_map(|module| {
115 let file_path = match file_id_to_path.get(&module.file_id) {
116 Some(p) => p,
117 None => {
118 tracing::warn!(
119 file_id = module.file_id.0,
120 "Skipping module with unknown file_id during resolution"
121 );
122 return None;
123 }
124 };
125
126 let resolved_imports: Vec<ResolvedImport> = module
127 .imports
128 .iter()
129 .map(|imp| ResolvedImport {
130 info: imp.clone(),
131 target: resolve_specifier(
132 &resolver,
133 file_path,
134 &imp.source,
135 &path_to_id,
136 &raw_path_to_id,
137 &bare_cache,
138 ),
139 })
140 .collect();
141
142 let resolved_dynamic_imports: Vec<ResolvedImport> = module
143 .dynamic_imports
144 .iter()
145 .flat_map(|imp| {
146 let target = resolve_specifier(
147 &resolver,
148 file_path,
149 &imp.source,
150 &path_to_id,
151 &raw_path_to_id,
152 &bare_cache,
153 );
154 if !imp.destructured_names.is_empty() {
155 imp.destructured_names
157 .iter()
158 .map(|name| ResolvedImport {
159 info: ImportInfo {
160 source: imp.source.clone(),
161 imported_name: crate::extract::ImportedName::Named(
162 name.clone(),
163 ),
164 local_name: name.clone(),
165 is_type_only: false,
166 span: imp.span,
167 },
168 target: target.clone(),
169 })
170 .collect()
171 } else if imp.local_name.is_some() {
172 vec![ResolvedImport {
174 info: ImportInfo {
175 source: imp.source.clone(),
176 imported_name: crate::extract::ImportedName::Namespace,
177 local_name: imp.local_name.clone().unwrap_or_default(),
178 is_type_only: false,
179 span: imp.span,
180 },
181 target,
182 }]
183 } else {
184 vec![ResolvedImport {
186 info: ImportInfo {
187 source: imp.source.clone(),
188 imported_name: crate::extract::ImportedName::SideEffect,
189 local_name: String::new(),
190 is_type_only: false,
191 span: imp.span,
192 },
193 target,
194 }]
195 }
196 })
197 .collect();
198
199 let re_exports: Vec<ResolvedReExport> = module
200 .re_exports
201 .iter()
202 .map(|re| ResolvedReExport {
203 info: re.clone(),
204 target: resolve_specifier(
205 &resolver,
206 file_path,
207 &re.source,
208 &path_to_id,
209 &raw_path_to_id,
210 &bare_cache,
211 ),
212 })
213 .collect();
214
215 let require_imports: Vec<ResolvedImport> = module
218 .require_calls
219 .iter()
220 .flat_map(|req| {
221 let target = resolve_specifier(
222 &resolver,
223 file_path,
224 &req.source,
225 &path_to_id,
226 &raw_path_to_id,
227 &bare_cache,
228 );
229 if req.destructured_names.is_empty() {
230 vec![ResolvedImport {
231 info: ImportInfo {
232 source: req.source.clone(),
233 imported_name: crate::extract::ImportedName::Namespace,
234 local_name: req.local_name.clone().unwrap_or_default(),
235 is_type_only: false,
236 span: req.span,
237 },
238 target,
239 }]
240 } else {
241 req.destructured_names
242 .iter()
243 .map(|name| ResolvedImport {
244 info: ImportInfo {
245 source: req.source.clone(),
246 imported_name: crate::extract::ImportedName::Named(
247 name.clone(),
248 ),
249 local_name: name.clone(),
250 is_type_only: false,
251 span: req.span,
252 },
253 target: target.clone(),
254 })
255 .collect()
256 }
257 })
258 .collect();
259
260 let mut all_imports = resolved_imports;
261 all_imports.extend(require_imports);
262
263 let from_dir = canonical_paths
266 .get(module.file_id.0 as usize)
267 .and_then(|p| p.parent())
268 .unwrap_or(file_path);
269 let resolved_dynamic_patterns: Vec<(
270 crate::extract::DynamicImportPattern,
271 Vec<FileId>,
272 )> = module
273 .dynamic_import_patterns
274 .iter()
275 .filter_map(|pattern| {
276 let glob_str = make_glob_from_pattern(pattern);
277 let matcher = globset::Glob::new(&glob_str)
278 .ok()
279 .map(|g| g.compile_matcher())?;
280 let matched: Vec<FileId> = canonical_paths
281 .iter()
282 .enumerate()
283 .filter(|(_idx, canonical)| {
284 if let Ok(relative) = canonical.strip_prefix(from_dir) {
285 let rel_str = format!("./{}", relative.to_string_lossy());
286 matcher.is_match(&rel_str)
287 } else {
288 false
289 }
290 })
291 .map(|(idx, _)| files[idx].id)
292 .collect();
293 if matched.is_empty() {
294 None
295 } else {
296 Some((pattern.clone(), matched))
297 }
298 })
299 .collect();
300
301 Some(ResolvedModule {
302 file_id: module.file_id,
303 path: file_path.to_path_buf(),
304 exports: module.exports.clone(),
305 re_exports,
306 resolved_imports: all_imports,
307 resolved_dynamic_imports,
308 resolved_dynamic_patterns,
309 member_accesses: module.member_accesses.clone(),
310 whole_object_uses: module.whole_object_uses.clone(),
311 has_cjs_exports: module.has_cjs_exports,
312 })
313 })
314 .collect()
315}
316
317fn create_resolver(config: &ResolvedConfig) -> Resolver {
319 let mut options = ResolveOptions {
320 extensions: vec![
321 ".ts".into(),
322 ".tsx".into(),
323 ".d.ts".into(),
324 ".d.mts".into(),
325 ".d.cts".into(),
326 ".mts".into(),
327 ".cts".into(),
328 ".js".into(),
329 ".jsx".into(),
330 ".mjs".into(),
331 ".cjs".into(),
332 ".json".into(),
333 ".vue".into(),
334 ".svelte".into(),
335 ],
336 extension_alias: vec![
339 (
340 ".js".into(),
341 vec![".ts".into(), ".tsx".into(), ".js".into()],
342 ),
343 (".jsx".into(), vec![".tsx".into(), ".jsx".into()]),
344 (".mjs".into(), vec![".mts".into(), ".mjs".into()]),
345 (".cjs".into(), vec![".cts".into(), ".cjs".into()]),
346 ],
347 condition_names: vec![
348 "import".into(),
349 "require".into(),
350 "default".into(),
351 "types".into(),
352 "node".into(),
353 ],
354 main_fields: vec!["module".into(), "main".into()],
355 ..Default::default()
356 };
357
358 let tsconfig_candidates = ["tsconfig.json", "tsconfig.app.json", "tsconfig.build.json"];
360 let root_tsconfig = tsconfig_candidates
361 .iter()
362 .map(|name| config.root.join(name))
363 .find(|p| p.exists());
364
365 if let Some(tsconfig) = root_tsconfig {
366 options.tsconfig = Some(oxc_resolver::TsconfigDiscovery::Manual(
368 oxc_resolver::TsconfigOptions {
369 config_file: tsconfig,
370 references: oxc_resolver::TsconfigReferences::Auto,
371 },
372 ));
373 } else {
374 options.tsconfig = Some(oxc_resolver::TsconfigDiscovery::Auto);
378 }
379
380 Resolver::new(options)
381}
382
383fn resolve_specifier(
385 resolver: &Resolver,
386 from_file: &Path,
387 specifier: &str,
388 path_to_id: &HashMap<&Path, FileId>,
389 raw_path_to_id: &HashMap<&Path, FileId>,
390 bare_cache: &BareSpecifierCache,
391) -> ResolveResult {
392 if specifier.contains("://") || specifier.starts_with("data:") {
394 return ResolveResult::ExternalFile(PathBuf::from(specifier));
395 }
396
397 let is_bare = is_bare_specifier(specifier);
399 if is_bare && let Some(cached) = bare_cache.get(specifier) {
400 return cached;
401 }
402
403 let dir = from_file.parent().unwrap_or(from_file);
404
405 let result = match resolver.resolve(dir, specifier) {
406 Ok(resolved) => {
407 let resolved_path = resolved.path();
408 if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
410 return ResolveResult::InternalModule(file_id);
411 }
412 match resolved_path.canonicalize() {
414 Ok(canonical) => {
415 if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
416 ResolveResult::InternalModule(file_id)
417 } else if let Some(pkg_name) =
418 extract_package_name_from_node_modules_path(&canonical)
419 {
420 ResolveResult::NpmPackage(pkg_name)
421 } else {
422 ResolveResult::ExternalFile(canonical)
423 }
424 }
425 Err(_) => {
426 if let Some(pkg_name) =
427 extract_package_name_from_node_modules_path(resolved_path)
428 {
429 ResolveResult::NpmPackage(pkg_name)
430 } else {
431 ResolveResult::ExternalFile(resolved_path.to_path_buf())
432 }
433 }
434 }
435 }
436 Err(_) => {
437 if is_bare {
438 let pkg_name = extract_package_name(specifier);
439 ResolveResult::NpmPackage(pkg_name)
440 } else {
441 ResolveResult::Unresolvable(specifier.to_string())
442 }
443 }
444 };
445
446 if is_bare {
448 bare_cache.insert(specifier.to_string(), result.clone());
449 }
450
451 result
452}
453
454fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
460 let components: Vec<&str> = path
461 .components()
462 .filter_map(|c| match c {
463 std::path::Component::Normal(s) => s.to_str(),
464 _ => None,
465 })
466 .collect();
467
468 let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
470
471 let after = &components[nm_idx + 1..];
472 if after.is_empty() {
473 return None;
474 }
475
476 if after[0].starts_with('@') {
477 if after.len() >= 2 {
479 Some(format!("{}/{}", after[0], after[1]))
480 } else {
481 Some(after[0].to_string())
482 }
483 } else {
484 Some(after[0].to_string())
485 }
486}
487
488fn make_glob_from_pattern(pattern: &crate::extract::DynamicImportPattern) -> String {
490 if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
492 return pattern.prefix.clone();
493 }
494 match &pattern.suffix {
495 Some(suffix) => format!("{}*{}", pattern.prefix, suffix),
496 None => format!("{}*", pattern.prefix),
497 }
498}
499
500fn is_bare_specifier(specifier: &str) -> bool {
502 !specifier.starts_with('.')
503 && !specifier.starts_with('/')
504 && !specifier.contains("://")
505 && !specifier.starts_with("data:")
506}
507
508pub fn extract_package_name(specifier: &str) -> String {
512 if specifier.starts_with('@') {
513 let parts: Vec<&str> = specifier.splitn(3, '/').collect();
514 if parts.len() >= 2 {
515 format!("{}/{}", parts[0], parts[1])
516 } else {
517 specifier.to_string()
518 }
519 } else {
520 specifier.split('/').next().unwrap_or(specifier).to_string()
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn test_extract_package_name() {
530 assert_eq!(extract_package_name("react"), "react");
531 assert_eq!(extract_package_name("lodash/merge"), "lodash");
532 assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
533 assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
534 }
535
536 #[test]
537 fn test_is_bare_specifier() {
538 assert!(is_bare_specifier("react"));
539 assert!(is_bare_specifier("@scope/pkg"));
540 assert!(is_bare_specifier("#internal/module"));
541 assert!(!is_bare_specifier("./utils"));
542 assert!(!is_bare_specifier("../lib"));
543 assert!(!is_bare_specifier("/absolute"));
544 }
545
546 #[test]
547 fn test_extract_package_name_from_node_modules_path_regular() {
548 let path = PathBuf::from("/project/node_modules/react/index.js");
549 assert_eq!(
550 extract_package_name_from_node_modules_path(&path),
551 Some("react".to_string())
552 );
553 }
554
555 #[test]
556 fn test_extract_package_name_from_node_modules_path_scoped() {
557 let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
558 assert_eq!(
559 extract_package_name_from_node_modules_path(&path),
560 Some("@babel/core".to_string())
561 );
562 }
563
564 #[test]
565 fn test_extract_package_name_from_node_modules_path_nested() {
566 let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
568 assert_eq!(
569 extract_package_name_from_node_modules_path(&path),
570 Some("pkg-b".to_string())
571 );
572 }
573
574 #[test]
575 fn test_extract_package_name_from_node_modules_path_deep_subpath() {
576 let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
577 assert_eq!(
578 extract_package_name_from_node_modules_path(&path),
579 Some("react-dom".to_string())
580 );
581 }
582
583 #[test]
584 fn test_extract_package_name_from_node_modules_path_no_node_modules() {
585 let path = PathBuf::from("/project/src/components/Button.tsx");
586 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
587 }
588
589 #[test]
590 fn test_extract_package_name_from_node_modules_path_just_node_modules() {
591 let path = PathBuf::from("/project/node_modules");
592 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
593 }
594
595 #[test]
596 fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
597 let path = PathBuf::from("/project/node_modules/@scope");
599 assert_eq!(
600 extract_package_name_from_node_modules_path(&path),
601 Some("@scope".to_string())
602 );
603 }
604
605 #[test]
606 fn test_resolve_specifier_node_modules_returns_npm_package() {
607 let path =
613 PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
614 assert_eq!(
615 extract_package_name_from_node_modules_path(&path),
616 Some("styled-components".to_string())
617 );
618
619 let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
620 assert_eq!(
621 extract_package_name_from_node_modules_path(&path),
622 Some("next".to_string())
623 );
624 }
625}