1use std::path::{Path, PathBuf};
9
10use rustc_hash::FxHashMap;
11
12use dashmap::DashMap;
13use oxc_resolver::{ResolveOptions, Resolver};
14use rayon::prelude::*;
15
16use fallow_types::discover::{DiscoveredFile, FileId};
17use fallow_types::extract::{ImportInfo, ModuleInfo, ReExportInfo};
18
19struct BareSpecifierCache {
25 cache: DashMap<String, ResolveResult>,
26}
27
28impl BareSpecifierCache {
29 fn new() -> Self {
30 Self {
31 cache: DashMap::new(),
32 }
33 }
34
35 fn get(&self, specifier: &str) -> Option<ResolveResult> {
36 self.cache.get(specifier).map(|entry| entry.clone())
37 }
38
39 fn insert(&self, specifier: String, result: ResolveResult) {
40 self.cache.insert(specifier, result);
41 }
42}
43
44#[derive(Debug, Clone)]
46pub enum ResolveResult {
47 InternalModule(FileId),
49 ExternalFile(PathBuf),
51 NpmPackage(String),
53 Unresolvable(String),
55}
56
57#[derive(Debug, Clone)]
59pub struct ResolvedImport {
60 pub info: ImportInfo,
62 pub target: ResolveResult,
64}
65
66#[derive(Debug, Clone)]
68pub struct ResolvedReExport {
69 pub info: ReExportInfo,
71 pub target: ResolveResult,
73}
74
75#[derive(Debug)]
77pub struct ResolvedModule {
78 pub file_id: FileId,
80 pub path: PathBuf,
82 pub exports: Vec<fallow_types::extract::ExportInfo>,
84 pub re_exports: Vec<ResolvedReExport>,
86 pub resolved_imports: Vec<ResolvedImport>,
88 pub resolved_dynamic_imports: Vec<ResolvedImport>,
90 pub resolved_dynamic_patterns: Vec<(fallow_types::extract::DynamicImportPattern, Vec<FileId>)>,
92 pub member_accesses: Vec<fallow_types::extract::MemberAccess>,
94 pub whole_object_uses: Vec<String>,
96 pub has_cjs_exports: bool,
98 pub unused_import_bindings: Vec<String>,
100}
101
102pub fn resolve_all_imports(
104 modules: &[ModuleInfo],
105 files: &[DiscoveredFile],
106 workspaces: &[fallow_config::WorkspaceInfo],
107 active_plugins: &[String],
108 path_aliases: &[(String, String)],
109 root: &Path,
110) -> Vec<ResolvedModule> {
111 let canonical_ws_roots: Vec<PathBuf> = workspaces
116 .par_iter()
117 .map(|ws| ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone()))
118 .collect();
119 let workspace_roots: FxHashMap<&str, &Path> = workspaces
120 .iter()
121 .zip(canonical_ws_roots.iter())
122 .map(|(ws, canonical)| (ws.name.as_str(), canonical.as_path()))
123 .collect();
124
125 let canonical_paths: Vec<PathBuf> = files
128 .par_iter()
129 .map(|f| f.path.canonicalize().unwrap_or_else(|_| f.path.clone()))
130 .collect();
131
132 let path_to_id: FxHashMap<&Path, FileId> = canonical_paths
134 .iter()
135 .enumerate()
136 .map(|(idx, canonical)| (canonical.as_path(), files[idx].id))
137 .collect();
138
139 let raw_path_to_id: FxHashMap<&Path, FileId> =
141 files.iter().map(|f| (f.path.as_path(), f.id)).collect();
142
143 let file_paths: Vec<&Path> = files.iter().map(|f| f.path.as_path()).collect();
145
146 let resolver = create_resolver(active_plugins);
148
149 let bare_cache = BareSpecifierCache::new();
151
152 modules
154 .par_iter()
155 .filter_map(|module| {
156 let Some(file_path) = file_paths.get(module.file_id.0 as usize) else {
157 tracing::warn!(
158 file_id = module.file_id.0,
159 "Skipping module with unknown file_id during resolution"
160 );
161 return None;
162 };
163
164 let resolved_imports: Vec<ResolvedImport> = module
165 .imports
166 .iter()
167 .map(|imp| ResolvedImport {
168 info: imp.clone(),
169 target: resolve_specifier(
170 &resolver,
171 file_path,
172 &imp.source,
173 &path_to_id,
174 &raw_path_to_id,
175 &bare_cache,
176 &workspace_roots,
177 path_aliases,
178 root,
179 ),
180 })
181 .collect();
182
183 let resolved_dynamic_imports: Vec<ResolvedImport> = module
184 .dynamic_imports
185 .iter()
186 .flat_map(|imp| {
187 let target = resolve_specifier(
188 &resolver,
189 file_path,
190 &imp.source,
191 &path_to_id,
192 &raw_path_to_id,
193 &bare_cache,
194 &workspace_roots,
195 path_aliases,
196 root,
197 );
198 if !imp.destructured_names.is_empty() {
199 imp.destructured_names
201 .iter()
202 .map(|name| ResolvedImport {
203 info: ImportInfo {
204 source: imp.source.clone(),
205 imported_name: fallow_types::extract::ImportedName::Named(
206 name.clone(),
207 ),
208 local_name: name.clone(),
209 is_type_only: false,
210 span: imp.span,
211 },
212 target: target.clone(),
213 })
214 .collect()
215 } else if imp.local_name.is_some() {
216 vec![ResolvedImport {
218 info: ImportInfo {
219 source: imp.source.clone(),
220 imported_name: fallow_types::extract::ImportedName::Namespace,
221 local_name: imp.local_name.clone().unwrap_or_default(),
222 is_type_only: false,
223 span: imp.span,
224 },
225 target,
226 }]
227 } else {
228 vec![ResolvedImport {
230 info: ImportInfo {
231 source: imp.source.clone(),
232 imported_name: fallow_types::extract::ImportedName::SideEffect,
233 local_name: String::new(),
234 is_type_only: false,
235 span: imp.span,
236 },
237 target,
238 }]
239 }
240 })
241 .collect();
242
243 let re_exports: Vec<ResolvedReExport> = module
244 .re_exports
245 .iter()
246 .map(|re| ResolvedReExport {
247 info: re.clone(),
248 target: resolve_specifier(
249 &resolver,
250 file_path,
251 &re.source,
252 &path_to_id,
253 &raw_path_to_id,
254 &bare_cache,
255 &workspace_roots,
256 path_aliases,
257 root,
258 ),
259 })
260 .collect();
261
262 let require_imports: Vec<ResolvedImport> = module
265 .require_calls
266 .iter()
267 .flat_map(|req| {
268 let target = resolve_specifier(
269 &resolver,
270 file_path,
271 &req.source,
272 &path_to_id,
273 &raw_path_to_id,
274 &bare_cache,
275 &workspace_roots,
276 path_aliases,
277 root,
278 );
279 if req.destructured_names.is_empty() {
280 vec![ResolvedImport {
281 info: ImportInfo {
282 source: req.source.clone(),
283 imported_name: fallow_types::extract::ImportedName::Namespace,
284 local_name: req.local_name.clone().unwrap_or_default(),
285 is_type_only: false,
286 span: req.span,
287 },
288 target,
289 }]
290 } else {
291 req.destructured_names
292 .iter()
293 .map(|name| ResolvedImport {
294 info: ImportInfo {
295 source: req.source.clone(),
296 imported_name: fallow_types::extract::ImportedName::Named(
297 name.clone(),
298 ),
299 local_name: name.clone(),
300 is_type_only: false,
301 span: req.span,
302 },
303 target: target.clone(),
304 })
305 .collect()
306 }
307 })
308 .collect();
309
310 let mut all_imports = resolved_imports;
311 all_imports.extend(require_imports);
312
313 let from_dir = canonical_paths
316 .get(module.file_id.0 as usize)
317 .and_then(|p| p.parent())
318 .unwrap_or(file_path);
319 let resolved_dynamic_patterns: Vec<(
320 fallow_types::extract::DynamicImportPattern,
321 Vec<FileId>,
322 )> = module
323 .dynamic_import_patterns
324 .iter()
325 .filter_map(|pattern| {
326 let glob_str = make_glob_from_pattern(pattern);
327 let matcher = globset::Glob::new(&glob_str)
328 .ok()
329 .map(|g| g.compile_matcher())?;
330 let matched: Vec<FileId> = canonical_paths
331 .iter()
332 .enumerate()
333 .filter(|(_idx, canonical)| {
334 canonical.strip_prefix(from_dir).is_ok_and(|relative| {
335 let rel_str = format!("./{}", relative.to_string_lossy());
336 matcher.is_match(&rel_str)
337 })
338 })
339 .map(|(idx, _)| files[idx].id)
340 .collect();
341 if matched.is_empty() {
342 None
343 } else {
344 Some((pattern.clone(), matched))
345 }
346 })
347 .collect();
348
349 Some(ResolvedModule {
350 file_id: module.file_id,
351 path: file_path.to_path_buf(),
352 exports: module.exports.clone(),
353 re_exports,
354 resolved_imports: all_imports,
355 resolved_dynamic_imports,
356 resolved_dynamic_patterns,
357 member_accesses: module.member_accesses.clone(),
358 whole_object_uses: module.whole_object_uses.clone(),
359 has_cjs_exports: module.has_cjs_exports,
360 unused_import_bindings: module.unused_import_bindings.clone(),
361 })
362 })
363 .collect()
364}
365
366pub fn is_path_alias(specifier: &str) -> bool {
373 if specifier.starts_with('#') {
375 return true;
376 }
377 if specifier.starts_with("~/") || specifier.starts_with("~~/") {
379 return true;
380 }
381 if specifier.starts_with("@/") {
383 return true;
384 }
385 if specifier.starts_with('@') {
389 let scope = specifier.split('/').next().unwrap_or(specifier);
390 if scope.len() > 1 && scope.chars().nth(1).is_some_and(|c| c.is_ascii_uppercase()) {
391 return true;
392 }
393 }
394
395 false
396}
397
398const RN_PLATFORM_PREFIXES: &[&str] = &[".web", ".ios", ".android", ".native"];
401
402fn has_react_native_plugin(active_plugins: &[String]) -> bool {
404 active_plugins
405 .iter()
406 .any(|p| p == "react-native" || p == "expo")
407}
408
409fn build_extensions(active_plugins: &[String]) -> Vec<String> {
412 let base: Vec<String> = vec![
413 ".ts".into(),
414 ".tsx".into(),
415 ".d.ts".into(),
416 ".d.mts".into(),
417 ".d.cts".into(),
418 ".mts".into(),
419 ".cts".into(),
420 ".js".into(),
421 ".jsx".into(),
422 ".mjs".into(),
423 ".cjs".into(),
424 ".json".into(),
425 ".vue".into(),
426 ".svelte".into(),
427 ".astro".into(),
428 ".mdx".into(),
429 ".css".into(),
430 ".scss".into(),
431 ];
432
433 if has_react_native_plugin(active_plugins) {
434 let source_exts = [".ts", ".tsx", ".js", ".jsx"];
435 let mut rn_extensions: Vec<String> = Vec::new();
436 for platform in RN_PLATFORM_PREFIXES {
437 for ext in &source_exts {
438 rn_extensions.push(format!("{platform}{ext}"));
439 }
440 }
441 rn_extensions.extend(base);
442 rn_extensions
443 } else {
444 base
445 }
446}
447
448fn build_condition_names(active_plugins: &[String]) -> Vec<String> {
451 let mut names = vec![
452 "import".into(),
453 "require".into(),
454 "default".into(),
455 "types".into(),
456 "node".into(),
457 ];
458 if has_react_native_plugin(active_plugins) {
459 names.insert(0, "react-native".into());
460 names.insert(1, "browser".into());
461 }
462 names
463}
464
465fn create_resolver(active_plugins: &[String]) -> Resolver {
471 let mut options = ResolveOptions {
472 extensions: build_extensions(active_plugins),
473 extension_alias: vec![
476 (
477 ".js".into(),
478 vec![".ts".into(), ".tsx".into(), ".js".into()],
479 ),
480 (".jsx".into(), vec![".tsx".into(), ".jsx".into()]),
481 (".mjs".into(), vec![".mts".into(), ".mjs".into()]),
482 (".cjs".into(), vec![".cts".into(), ".cjs".into()]),
483 ],
484 condition_names: build_condition_names(active_plugins),
485 main_fields: vec!["module".into(), "main".into()],
486 ..Default::default()
487 };
488
489 options.tsconfig = Some(oxc_resolver::TsconfigDiscovery::Auto);
497
498 Resolver::new(options)
499}
500
501#[expect(clippy::too_many_arguments)]
503fn resolve_specifier(
504 resolver: &Resolver,
505 from_file: &Path,
506 specifier: &str,
507 path_to_id: &FxHashMap<&Path, FileId>,
508 raw_path_to_id: &FxHashMap<&Path, FileId>,
509 bare_cache: &BareSpecifierCache,
510 workspace_roots: &FxHashMap<&str, &Path>,
511 path_aliases: &[(String, String)],
512 root: &Path,
513) -> ResolveResult {
514 if specifier.contains("://") || specifier.starts_with("data:") {
516 return ResolveResult::ExternalFile(PathBuf::from(specifier));
517 }
518
519 let is_bare = is_bare_specifier(specifier);
523 let is_alias = is_path_alias(specifier);
524 if is_bare
525 && !is_alias
526 && let Some(cached) = bare_cache.get(specifier)
527 {
528 return cached;
529 }
530
531 let (result, resolver_succeeded) = match resolver.resolve_file(from_file, specifier) {
542 Ok(resolved) => {
543 let resolved_path = resolved.path();
544 if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
546 let result = ResolveResult::InternalModule(file_id);
547 if is_bare && !is_alias {
549 bare_cache.insert(specifier.to_string(), result.clone());
550 }
551 return result;
552 }
553 let result = match resolved_path.canonicalize() {
555 Ok(canonical) => {
556 if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
557 ResolveResult::InternalModule(file_id)
558 } else if let Some(file_id) = try_source_fallback(&canonical, path_to_id) {
559 ResolveResult::InternalModule(file_id)
562 } else if let Some(file_id) =
563 try_pnpm_workspace_fallback(&canonical, path_to_id, workspace_roots)
564 {
565 ResolveResult::InternalModule(file_id)
566 } else if let Some(pkg_name) =
567 extract_package_name_from_node_modules_path(&canonical)
568 {
569 ResolveResult::NpmPackage(pkg_name)
570 } else {
571 ResolveResult::ExternalFile(canonical)
572 }
573 }
574 Err(_) => {
575 if let Some(file_id) = try_source_fallback(resolved_path, path_to_id) {
577 ResolveResult::InternalModule(file_id)
578 } else if let Some(file_id) =
579 try_pnpm_workspace_fallback(resolved_path, path_to_id, workspace_roots)
580 {
581 ResolveResult::InternalModule(file_id)
582 } else if let Some(pkg_name) =
583 extract_package_name_from_node_modules_path(resolved_path)
584 {
585 ResolveResult::NpmPackage(pkg_name)
586 } else {
587 ResolveResult::ExternalFile(resolved_path.to_path_buf())
588 }
589 }
590 };
591 (result, true)
592 }
593 Err(_) => {
594 let result = if is_alias {
595 if let Some(resolved) = try_path_alias_fallback(
599 resolver,
600 specifier,
601 path_aliases,
602 root,
603 path_to_id,
604 raw_path_to_id,
605 workspace_roots,
606 ) {
607 resolved
608 } else {
609 ResolveResult::Unresolvable(specifier.to_string())
612 }
613 } else if is_bare {
614 let pkg_name = extract_package_name(specifier);
615 ResolveResult::NpmPackage(pkg_name)
616 } else {
617 ResolveResult::Unresolvable(specifier.to_string())
618 };
619 (result, false)
620 }
621 };
622
623 if is_bare && !is_alias && resolver_succeeded {
630 bare_cache.insert(specifier.to_string(), result.clone());
631 }
632
633 result
634}
635
636fn try_path_alias_fallback(
643 resolver: &Resolver,
644 specifier: &str,
645 path_aliases: &[(String, String)],
646 root: &Path,
647 path_to_id: &FxHashMap<&Path, FileId>,
648 raw_path_to_id: &FxHashMap<&Path, FileId>,
649 workspace_roots: &FxHashMap<&str, &Path>,
650) -> Option<ResolveResult> {
651 for (prefix, replacement) in path_aliases {
652 if !specifier.starts_with(prefix.as_str()) {
653 continue;
654 }
655
656 let remainder = &specifier[prefix.len()..];
657 let substituted = if replacement.is_empty() {
660 format!("./{remainder}")
661 } else {
662 format!("./{replacement}/{remainder}")
663 };
664
665 let root_file = root.join("__resolve_root__");
668 if let Ok(resolved) = resolver.resolve_file(&root_file, &substituted) {
669 let resolved_path = resolved.path();
670 if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
672 return Some(ResolveResult::InternalModule(file_id));
673 }
674 if let Ok(canonical) = resolved_path.canonicalize() {
676 if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
677 return Some(ResolveResult::InternalModule(file_id));
678 }
679 if let Some(file_id) = try_source_fallback(&canonical, path_to_id) {
680 return Some(ResolveResult::InternalModule(file_id));
681 }
682 if let Some(file_id) =
683 try_pnpm_workspace_fallback(&canonical, path_to_id, workspace_roots)
684 {
685 return Some(ResolveResult::InternalModule(file_id));
686 }
687 if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
688 return Some(ResolveResult::NpmPackage(pkg_name));
689 }
690 return Some(ResolveResult::ExternalFile(canonical));
691 }
692 }
693 }
694 None
695}
696
697const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
702
703const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
705
706fn try_source_fallback(resolved: &Path, path_to_id: &FxHashMap<&Path, FileId>) -> Option<FileId> {
718 let components: Vec<_> = resolved.components().collect();
719
720 let is_output_dir = |c: &std::path::Component| -> bool {
721 if let std::path::Component::Normal(s) = c
722 && let Some(name) = s.to_str()
723 {
724 return OUTPUT_DIRS.contains(&name);
725 }
726 false
727 };
728
729 let last_output_pos = components.iter().rposition(&is_output_dir)?;
733
734 let mut first_output_pos = last_output_pos;
737 while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
738 first_output_pos -= 1;
739 }
740
741 let prefix: PathBuf = components[..first_output_pos].iter().collect();
743
744 let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
746 suffix.file_stem()?; for ext in SOURCE_EXTS {
750 let source_candidate = prefix.join("src").join(suffix.with_extension(ext));
751 if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
752 return Some(file_id);
753 }
754 }
755
756 None
757}
758
759fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
765 let components: Vec<&str> = path
766 .components()
767 .filter_map(|c| match c {
768 std::path::Component::Normal(s) => s.to_str(),
769 _ => None,
770 })
771 .collect();
772
773 let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
775
776 let after = &components[nm_idx + 1..];
777 if after.is_empty() {
778 return None;
779 }
780
781 if after[0].starts_with('@') {
782 if after.len() >= 2 {
784 Some(format!("{}/{}", after[0], after[1]))
785 } else {
786 Some(after[0].to_string())
787 }
788 } else {
789 Some(after[0].to_string())
790 }
791}
792
793fn try_pnpm_workspace_fallback(
802 path: &Path,
803 path_to_id: &FxHashMap<&Path, FileId>,
804 workspace_roots: &FxHashMap<&str, &Path>,
805) -> Option<FileId> {
806 let components: Vec<&str> = path
808 .components()
809 .filter_map(|c| match c {
810 std::path::Component::Normal(s) => s.to_str(),
811 _ => None,
812 })
813 .collect();
814
815 let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
817
818 let after_pnpm = &components[pnpm_idx + 1..];
821
822 let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
824 let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
825
826 if after_inner_nm.is_empty() {
827 return None;
828 }
829
830 let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
832 if after_inner_nm.len() >= 2 {
833 (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
834 } else {
835 return None;
836 }
837 } else {
838 (after_inner_nm[0].to_string(), 1)
839 };
840
841 let ws_root = workspace_roots.get(pkg_name.as_str())?;
843
844 let relative_parts = &after_inner_nm[pkg_name_components..];
846 if relative_parts.is_empty() {
847 return None;
848 }
849
850 let relative_path: PathBuf = relative_parts.iter().collect();
851
852 let direct = ws_root.join(&relative_path);
854 if let Some(&file_id) = path_to_id.get(direct.as_path()) {
855 return Some(file_id);
856 }
857
858 try_source_fallback(&direct, path_to_id)
860}
861
862fn make_glob_from_pattern(pattern: &fallow_types::extract::DynamicImportPattern) -> String {
864 if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
866 return pattern.prefix.clone();
867 }
868 pattern.suffix.as_ref().map_or_else(
869 || format!("{}*", pattern.prefix),
870 |suffix| format!("{}*{}", pattern.prefix, suffix),
871 )
872}
873
874fn is_bare_specifier(specifier: &str) -> bool {
876 !specifier.starts_with('.')
877 && !specifier.starts_with('/')
878 && !specifier.contains("://")
879 && !specifier.starts_with("data:")
880}
881
882pub fn extract_package_name(specifier: &str) -> String {
886 if specifier.starts_with('@') {
887 let parts: Vec<&str> = specifier.splitn(3, '/').collect();
888 if parts.len() >= 2 {
889 format!("{}/{}", parts[0], parts[1])
890 } else {
891 specifier.to_string()
892 }
893 } else {
894 specifier.split('/').next().unwrap_or(specifier).to_string()
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901
902 #[test]
903 fn test_extract_package_name() {
904 assert_eq!(extract_package_name("react"), "react");
905 assert_eq!(extract_package_name("lodash/merge"), "lodash");
906 assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
907 assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
908 }
909
910 #[test]
911 fn test_is_bare_specifier() {
912 assert!(is_bare_specifier("react"));
913 assert!(is_bare_specifier("@scope/pkg"));
914 assert!(is_bare_specifier("#internal/module"));
915 assert!(!is_bare_specifier("./utils"));
916 assert!(!is_bare_specifier("../lib"));
917 assert!(!is_bare_specifier("/absolute"));
918 }
919
920 #[test]
921 fn test_extract_package_name_from_node_modules_path_regular() {
922 let path = PathBuf::from("/project/node_modules/react/index.js");
923 assert_eq!(
924 extract_package_name_from_node_modules_path(&path),
925 Some("react".to_string())
926 );
927 }
928
929 #[test]
930 fn test_extract_package_name_from_node_modules_path_scoped() {
931 let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
932 assert_eq!(
933 extract_package_name_from_node_modules_path(&path),
934 Some("@babel/core".to_string())
935 );
936 }
937
938 #[test]
939 fn test_extract_package_name_from_node_modules_path_nested() {
940 let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
942 assert_eq!(
943 extract_package_name_from_node_modules_path(&path),
944 Some("pkg-b".to_string())
945 );
946 }
947
948 #[test]
949 fn test_extract_package_name_from_node_modules_path_deep_subpath() {
950 let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
951 assert_eq!(
952 extract_package_name_from_node_modules_path(&path),
953 Some("react-dom".to_string())
954 );
955 }
956
957 #[test]
958 fn test_extract_package_name_from_node_modules_path_no_node_modules() {
959 let path = PathBuf::from("/project/src/components/Button.tsx");
960 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
961 }
962
963 #[test]
964 fn test_extract_package_name_from_node_modules_path_just_node_modules() {
965 let path = PathBuf::from("/project/node_modules");
966 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
967 }
968
969 #[test]
970 fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
971 let path = PathBuf::from("/project/node_modules/@scope");
973 assert_eq!(
974 extract_package_name_from_node_modules_path(&path),
975 Some("@scope".to_string())
976 );
977 }
978
979 #[test]
980 fn test_resolve_specifier_node_modules_returns_npm_package() {
981 let path =
987 PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
988 assert_eq!(
989 extract_package_name_from_node_modules_path(&path),
990 Some("styled-components".to_string())
991 );
992
993 let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
994 assert_eq!(
995 extract_package_name_from_node_modules_path(&path),
996 Some("next".to_string())
997 );
998 }
999
1000 #[test]
1001 fn test_try_source_fallback_dist_to_src() {
1002 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1003 let mut path_to_id = FxHashMap::default();
1004 path_to_id.insert(src_path.as_path(), FileId(0));
1005
1006 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1007 assert_eq!(
1008 try_source_fallback(&dist_path, &path_to_id),
1009 Some(FileId(0)),
1010 "dist/utils.js should fall back to src/utils.ts"
1011 );
1012 }
1013
1014 #[test]
1015 fn test_try_source_fallback_build_to_src() {
1016 let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1017 let mut path_to_id = FxHashMap::default();
1018 path_to_id.insert(src_path.as_path(), FileId(1));
1019
1020 let build_path = PathBuf::from("/project/packages/core/build/index.js");
1021 assert_eq!(
1022 try_source_fallback(&build_path, &path_to_id),
1023 Some(FileId(1)),
1024 "build/index.js should fall back to src/index.tsx"
1025 );
1026 }
1027
1028 #[test]
1029 fn test_try_source_fallback_no_match() {
1030 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1031
1032 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1033 assert_eq!(
1034 try_source_fallback(&dist_path, &path_to_id),
1035 None,
1036 "should return None when no source file exists"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_try_source_fallback_non_output_dir() {
1042 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1043 let mut path_to_id = FxHashMap::default();
1044 path_to_id.insert(src_path.as_path(), FileId(0));
1045
1046 let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1048 assert_eq!(
1049 try_source_fallback(&normal_path, &path_to_id),
1050 None,
1051 "non-output directory path should not trigger fallback"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_try_source_fallback_nested_path() {
1057 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1058 let mut path_to_id = FxHashMap::default();
1059 path_to_id.insert(src_path.as_path(), FileId(2));
1060
1061 let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1062 assert_eq!(
1063 try_source_fallback(&dist_path, &path_to_id),
1064 Some(FileId(2)),
1065 "nested dist path should fall back to nested src path"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_try_source_fallback_nested_dist_esm() {
1071 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1072 let mut path_to_id = FxHashMap::default();
1073 path_to_id.insert(src_path.as_path(), FileId(0));
1074
1075 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1076 assert_eq!(
1077 try_source_fallback(&dist_path, &path_to_id),
1078 Some(FileId(0)),
1079 "dist/esm/utils.mjs should fall back to src/utils.ts"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_try_source_fallback_nested_build_cjs() {
1085 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1086 let mut path_to_id = FxHashMap::default();
1087 path_to_id.insert(src_path.as_path(), FileId(1));
1088
1089 let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1090 assert_eq!(
1091 try_source_fallback(&build_path, &path_to_id),
1092 Some(FileId(1)),
1093 "build/cjs/index.cjs should fall back to src/index.ts"
1094 );
1095 }
1096
1097 #[test]
1098 fn test_try_source_fallback_nested_dist_esm_deep_path() {
1099 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1100 let mut path_to_id = FxHashMap::default();
1101 path_to_id.insert(src_path.as_path(), FileId(2));
1102
1103 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1104 assert_eq!(
1105 try_source_fallback(&dist_path, &path_to_id),
1106 Some(FileId(2)),
1107 "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1108 );
1109 }
1110
1111 #[test]
1112 fn test_try_source_fallback_triple_nested_output_dirs() {
1113 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1114 let mut path_to_id = FxHashMap::default();
1115 path_to_id.insert(src_path.as_path(), FileId(0));
1116
1117 let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1118 assert_eq!(
1119 try_source_fallback(&dist_path, &path_to_id),
1120 Some(FileId(0)),
1121 "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_try_source_fallback_parent_dir_named_build() {
1127 let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1128 let mut path_to_id = FxHashMap::default();
1129 path_to_id.insert(src_path.as_path(), FileId(0));
1130
1131 let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1132 assert_eq!(
1133 try_source_fallback(&dist_path, &path_to_id),
1134 Some(FileId(0)),
1135 "should resolve dist/ within project, not match parent 'build' dir"
1136 );
1137 }
1138
1139 #[test]
1140 fn test_pnpm_store_path_extract_package_name() {
1141 let path =
1143 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1144 assert_eq!(
1145 extract_package_name_from_node_modules_path(&path),
1146 Some("react".to_string())
1147 );
1148 }
1149
1150 #[test]
1151 fn test_pnpm_store_path_scoped_package() {
1152 let path = PathBuf::from(
1153 "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1154 );
1155 assert_eq!(
1156 extract_package_name_from_node_modules_path(&path),
1157 Some("@babel/core".to_string())
1158 );
1159 }
1160
1161 #[test]
1162 fn test_pnpm_store_path_with_peer_deps() {
1163 let path = PathBuf::from(
1164 "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
1165 );
1166 assert_eq!(
1167 extract_package_name_from_node_modules_path(&path),
1168 Some("webpack".to_string())
1169 );
1170 }
1171
1172 #[test]
1173 fn test_try_pnpm_workspace_fallback_dist_to_src() {
1174 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1175 let mut path_to_id = FxHashMap::default();
1176 path_to_id.insert(src_path.as_path(), FileId(0));
1177
1178 let mut workspace_roots = FxHashMap::default();
1179 let ws_root = PathBuf::from("/project/packages/ui");
1180 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1181
1182 let pnpm_path = PathBuf::from(
1184 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
1185 );
1186 assert_eq!(
1187 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1188 Some(FileId(0)),
1189 ".pnpm workspace path should fall back to src/utils.ts"
1190 );
1191 }
1192
1193 #[test]
1194 fn test_try_pnpm_workspace_fallback_direct_source() {
1195 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1196 let mut path_to_id = FxHashMap::default();
1197 path_to_id.insert(src_path.as_path(), FileId(1));
1198
1199 let mut workspace_roots = FxHashMap::default();
1200 let ws_root = PathBuf::from("/project/packages/core");
1201 workspace_roots.insert("@myorg/core", ws_root.as_path());
1202
1203 let pnpm_path = PathBuf::from(
1205 "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
1206 );
1207 assert_eq!(
1208 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1209 Some(FileId(1)),
1210 ".pnpm workspace path with src/ should resolve directly"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_try_pnpm_workspace_fallback_non_workspace_package() {
1216 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1217
1218 let mut workspace_roots = FxHashMap::default();
1219 let ws_root = PathBuf::from("/project/packages/ui");
1220 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1221
1222 let pnpm_path =
1224 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1225 assert_eq!(
1226 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1227 None,
1228 "non-workspace package in .pnpm should return None"
1229 );
1230 }
1231
1232 #[test]
1233 fn test_try_pnpm_workspace_fallback_unscoped_package() {
1234 let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
1235 let mut path_to_id = FxHashMap::default();
1236 path_to_id.insert(src_path.as_path(), FileId(2));
1237
1238 let mut workspace_roots = FxHashMap::default();
1239 let ws_root = PathBuf::from("/project/packages/utils");
1240 workspace_roots.insert("my-utils", ws_root.as_path());
1241
1242 let pnpm_path = PathBuf::from(
1244 "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
1245 );
1246 assert_eq!(
1247 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1248 Some(FileId(2)),
1249 "unscoped workspace package in .pnpm should resolve"
1250 );
1251 }
1252
1253 #[test]
1254 fn test_try_pnpm_workspace_fallback_nested_path() {
1255 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1256 let mut path_to_id = FxHashMap::default();
1257 path_to_id.insert(src_path.as_path(), FileId(3));
1258
1259 let mut workspace_roots = FxHashMap::default();
1260 let ws_root = PathBuf::from("/project/packages/ui");
1261 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1262
1263 let pnpm_path = PathBuf::from(
1265 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1266 );
1267 assert_eq!(
1268 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1269 Some(FileId(3)),
1270 "nested .pnpm workspace path should resolve through source fallback"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_try_pnpm_workspace_fallback_no_pnpm() {
1276 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1277 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1278
1279 let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1281 assert_eq!(
1282 try_pnpm_workspace_fallback(®ular_path, &path_to_id, &workspace_roots),
1283 None,
1284 );
1285 }
1286
1287 #[test]
1288 fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1289 let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1290 let mut path_to_id = FxHashMap::default();
1291 path_to_id.insert(src_path.as_path(), FileId(4));
1292
1293 let mut workspace_roots = FxHashMap::default();
1294 let ws_root = PathBuf::from("/project/packages/ui");
1295 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1296
1297 let pnpm_path = PathBuf::from(
1299 "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1300 );
1301 assert_eq!(
1302 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1303 Some(FileId(4)),
1304 ".pnpm path with peer dep suffix should still resolve"
1305 );
1306 }
1307
1308 #[test]
1309 fn test_has_react_native_plugin_active() {
1310 let plugins = vec!["react-native".to_string(), "typescript".to_string()];
1311 assert!(has_react_native_plugin(&plugins));
1312 }
1313
1314 #[test]
1315 fn test_has_expo_plugin_active() {
1316 let plugins = vec!["expo".to_string(), "typescript".to_string()];
1317 assert!(has_react_native_plugin(&plugins));
1318 }
1319
1320 #[test]
1321 fn test_has_react_native_plugin_inactive() {
1322 let plugins = vec!["nextjs".to_string(), "typescript".to_string()];
1323 assert!(!has_react_native_plugin(&plugins));
1324 }
1325
1326 #[test]
1327 fn test_rn_platform_extensions_prepended() {
1328 let no_rn = build_extensions(&[]);
1329 let rn_plugins = vec!["react-native".to_string()];
1330 let with_rn = build_extensions(&rn_plugins);
1331
1332 assert_eq!(no_rn[0], ".ts");
1334
1335 assert_eq!(with_rn[0], ".web.ts");
1337 assert_eq!(with_rn[1], ".web.tsx");
1338 assert_eq!(with_rn[2], ".web.js");
1339 assert_eq!(with_rn[3], ".web.jsx");
1340
1341 assert!(with_rn.len() > no_rn.len());
1343 assert_eq!(
1344 with_rn.len(),
1345 no_rn.len() + 16,
1346 "should add 16 platform extensions (4 platforms x 4 exts)"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_rn_condition_names_prepended() {
1352 let no_rn = build_condition_names(&[]);
1353 let rn_plugins = vec!["react-native".to_string()];
1354 let with_rn = build_condition_names(&rn_plugins);
1355
1356 assert_eq!(no_rn[0], "import");
1358
1359 assert_eq!(with_rn[0], "react-native");
1361 assert_eq!(with_rn[1], "browser");
1362 assert_eq!(with_rn[2], "import");
1363 }
1364
1365 mod proptests {
1366 use super::*;
1367 use proptest::prelude::*;
1368
1369 proptest! {
1370 #[test]
1372 fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1373 let dot = format!(".{suffix}");
1374 let slash = format!("/{suffix}");
1375 prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
1376 prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
1377 }
1378
1379 #[test]
1381 fn scoped_package_name_has_two_segments(
1382 scope in "[a-z][a-z0-9-]{0,20}",
1383 pkg in "[a-z][a-z0-9-]{0,20}",
1384 subpath in "(/[a-z0-9-]{1,20}){0,3}",
1385 ) {
1386 let specifier = format!("@{scope}/{pkg}{subpath}");
1387 let extracted = extract_package_name(&specifier);
1388 let expected = format!("@{scope}/{pkg}");
1389 prop_assert_eq!(extracted, expected);
1390 }
1391
1392 #[test]
1394 fn unscoped_package_name_is_first_segment(
1395 pkg in "[a-z][a-z0-9-]{0,30}",
1396 subpath in "(/[a-z0-9-]{1,20}){0,3}",
1397 ) {
1398 let specifier = format!("{pkg}{subpath}");
1399 let extracted = extract_package_name(&specifier);
1400 prop_assert_eq!(extracted, pkg);
1401 }
1402
1403 #[test]
1405 fn bare_specifier_and_path_alias_no_panic(s in "[a-zA-Z0-9@#~/._-]{1,100}") {
1406 let _ = is_bare_specifier(&s);
1407 let _ = is_path_alias(&s);
1408 }
1409
1410 #[test]
1412 fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1413 let specifier = format!("@/{suffix}");
1414 prop_assert!(is_path_alias(&specifier));
1415 }
1416
1417 #[test]
1419 fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1420 let specifier = format!("~/{suffix}");
1421 prop_assert!(is_path_alias(&specifier));
1422 }
1423
1424 #[test]
1426 fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1427 let specifier = format!("#{suffix}");
1428 prop_assert!(is_path_alias(&specifier));
1429 }
1430
1431 #[test]
1433 fn node_modules_package_name_never_empty(
1434 pkg in "[a-z][a-z0-9-]{0,20}",
1435 file in "[a-z]{1,10}\\.(js|ts|mjs)",
1436 ) {
1437 let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
1438 if let Some(name) = extract_package_name_from_node_modules_path(&path) {
1439 prop_assert!(!name.is_empty());
1440 }
1441 }
1442 }
1443 }
1444}