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 = match resolver.resolve_file(from_file, specifier) {
536 Ok(resolved) => {
537 let resolved_path = resolved.path();
538 if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
540 return ResolveResult::InternalModule(file_id);
541 }
542 match resolved_path.canonicalize() {
544 Ok(canonical) => {
545 if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
546 ResolveResult::InternalModule(file_id)
547 } else if let Some(file_id) = try_source_fallback(&canonical, path_to_id) {
548 ResolveResult::InternalModule(file_id)
551 } else if let Some(file_id) =
552 try_pnpm_workspace_fallback(&canonical, path_to_id, workspace_roots)
553 {
554 ResolveResult::InternalModule(file_id)
555 } else if let Some(pkg_name) =
556 extract_package_name_from_node_modules_path(&canonical)
557 {
558 ResolveResult::NpmPackage(pkg_name)
559 } else {
560 ResolveResult::ExternalFile(canonical)
561 }
562 }
563 Err(_) => {
564 if let Some(file_id) = try_source_fallback(resolved_path, path_to_id) {
566 ResolveResult::InternalModule(file_id)
567 } else if let Some(file_id) =
568 try_pnpm_workspace_fallback(resolved_path, path_to_id, workspace_roots)
569 {
570 ResolveResult::InternalModule(file_id)
571 } else if let Some(pkg_name) =
572 extract_package_name_from_node_modules_path(resolved_path)
573 {
574 ResolveResult::NpmPackage(pkg_name)
575 } else {
576 ResolveResult::ExternalFile(resolved_path.to_path_buf())
577 }
578 }
579 }
580 }
581 Err(_) => {
582 if is_alias {
583 if let Some(resolved) = try_path_alias_fallback(
587 resolver,
588 specifier,
589 path_aliases,
590 root,
591 path_to_id,
592 raw_path_to_id,
593 workspace_roots,
594 ) {
595 resolved
596 } else {
597 ResolveResult::Unresolvable(specifier.to_string())
600 }
601 } else if is_bare {
602 let pkg_name = extract_package_name(specifier);
603 ResolveResult::NpmPackage(pkg_name)
604 } else {
605 ResolveResult::Unresolvable(specifier.to_string())
606 }
607 }
608 };
609
610 if is_bare && !is_alias {
613 bare_cache.insert(specifier.to_string(), result.clone());
614 }
615
616 result
617}
618
619fn try_path_alias_fallback(
626 resolver: &Resolver,
627 specifier: &str,
628 path_aliases: &[(String, String)],
629 root: &Path,
630 path_to_id: &FxHashMap<&Path, FileId>,
631 raw_path_to_id: &FxHashMap<&Path, FileId>,
632 workspace_roots: &FxHashMap<&str, &Path>,
633) -> Option<ResolveResult> {
634 for (prefix, replacement) in path_aliases {
635 if !specifier.starts_with(prefix.as_str()) {
636 continue;
637 }
638
639 let remainder = &specifier[prefix.len()..];
640 let substituted = if replacement.is_empty() {
643 format!("./{remainder}")
644 } else {
645 format!("./{replacement}/{remainder}")
646 };
647
648 let root_file = root.join("__resolve_root__");
651 if let Ok(resolved) = resolver.resolve_file(&root_file, &substituted) {
652 let resolved_path = resolved.path();
653 if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
655 return Some(ResolveResult::InternalModule(file_id));
656 }
657 if let Ok(canonical) = resolved_path.canonicalize() {
659 if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
660 return Some(ResolveResult::InternalModule(file_id));
661 }
662 if let Some(file_id) = try_source_fallback(&canonical, path_to_id) {
663 return Some(ResolveResult::InternalModule(file_id));
664 }
665 if let Some(file_id) =
666 try_pnpm_workspace_fallback(&canonical, path_to_id, workspace_roots)
667 {
668 return Some(ResolveResult::InternalModule(file_id));
669 }
670 if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
671 return Some(ResolveResult::NpmPackage(pkg_name));
672 }
673 return Some(ResolveResult::ExternalFile(canonical));
674 }
675 }
676 }
677 None
678}
679
680const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
685
686const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
688
689fn try_source_fallback(resolved: &Path, path_to_id: &FxHashMap<&Path, FileId>) -> Option<FileId> {
701 let components: Vec<_> = resolved.components().collect();
702
703 let is_output_dir = |c: &std::path::Component| -> bool {
704 if let std::path::Component::Normal(s) = c
705 && let Some(name) = s.to_str()
706 {
707 return OUTPUT_DIRS.contains(&name);
708 }
709 false
710 };
711
712 let last_output_pos = components.iter().rposition(&is_output_dir)?;
716
717 let mut first_output_pos = last_output_pos;
720 while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
721 first_output_pos -= 1;
722 }
723
724 let prefix: PathBuf = components[..first_output_pos].iter().collect();
726
727 let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
729 suffix.file_stem()?; for ext in SOURCE_EXTS {
733 let source_candidate = prefix.join("src").join(suffix.with_extension(ext));
734 if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
735 return Some(file_id);
736 }
737 }
738
739 None
740}
741
742fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
748 let components: Vec<&str> = path
749 .components()
750 .filter_map(|c| match c {
751 std::path::Component::Normal(s) => s.to_str(),
752 _ => None,
753 })
754 .collect();
755
756 let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
758
759 let after = &components[nm_idx + 1..];
760 if after.is_empty() {
761 return None;
762 }
763
764 if after[0].starts_with('@') {
765 if after.len() >= 2 {
767 Some(format!("{}/{}", after[0], after[1]))
768 } else {
769 Some(after[0].to_string())
770 }
771 } else {
772 Some(after[0].to_string())
773 }
774}
775
776fn try_pnpm_workspace_fallback(
785 path: &Path,
786 path_to_id: &FxHashMap<&Path, FileId>,
787 workspace_roots: &FxHashMap<&str, &Path>,
788) -> Option<FileId> {
789 let components: Vec<&str> = path
791 .components()
792 .filter_map(|c| match c {
793 std::path::Component::Normal(s) => s.to_str(),
794 _ => None,
795 })
796 .collect();
797
798 let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
800
801 let after_pnpm = &components[pnpm_idx + 1..];
804
805 let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
807 let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
808
809 if after_inner_nm.is_empty() {
810 return None;
811 }
812
813 let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
815 if after_inner_nm.len() >= 2 {
816 (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
817 } else {
818 return None;
819 }
820 } else {
821 (after_inner_nm[0].to_string(), 1)
822 };
823
824 let ws_root = workspace_roots.get(pkg_name.as_str())?;
826
827 let relative_parts = &after_inner_nm[pkg_name_components..];
829 if relative_parts.is_empty() {
830 return None;
831 }
832
833 let relative_path: PathBuf = relative_parts.iter().collect();
834
835 let direct = ws_root.join(&relative_path);
837 if let Some(&file_id) = path_to_id.get(direct.as_path()) {
838 return Some(file_id);
839 }
840
841 try_source_fallback(&direct, path_to_id)
843}
844
845fn make_glob_from_pattern(pattern: &fallow_types::extract::DynamicImportPattern) -> String {
847 if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
849 return pattern.prefix.clone();
850 }
851 pattern.suffix.as_ref().map_or_else(
852 || format!("{}*", pattern.prefix),
853 |suffix| format!("{}*{}", pattern.prefix, suffix),
854 )
855}
856
857fn is_bare_specifier(specifier: &str) -> bool {
859 !specifier.starts_with('.')
860 && !specifier.starts_with('/')
861 && !specifier.contains("://")
862 && !specifier.starts_with("data:")
863}
864
865pub fn extract_package_name(specifier: &str) -> String {
869 if specifier.starts_with('@') {
870 let parts: Vec<&str> = specifier.splitn(3, '/').collect();
871 if parts.len() >= 2 {
872 format!("{}/{}", parts[0], parts[1])
873 } else {
874 specifier.to_string()
875 }
876 } else {
877 specifier.split('/').next().unwrap_or(specifier).to_string()
878 }
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884
885 #[test]
886 fn test_extract_package_name() {
887 assert_eq!(extract_package_name("react"), "react");
888 assert_eq!(extract_package_name("lodash/merge"), "lodash");
889 assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
890 assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
891 }
892
893 #[test]
894 fn test_is_bare_specifier() {
895 assert!(is_bare_specifier("react"));
896 assert!(is_bare_specifier("@scope/pkg"));
897 assert!(is_bare_specifier("#internal/module"));
898 assert!(!is_bare_specifier("./utils"));
899 assert!(!is_bare_specifier("../lib"));
900 assert!(!is_bare_specifier("/absolute"));
901 }
902
903 #[test]
904 fn test_extract_package_name_from_node_modules_path_regular() {
905 let path = PathBuf::from("/project/node_modules/react/index.js");
906 assert_eq!(
907 extract_package_name_from_node_modules_path(&path),
908 Some("react".to_string())
909 );
910 }
911
912 #[test]
913 fn test_extract_package_name_from_node_modules_path_scoped() {
914 let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
915 assert_eq!(
916 extract_package_name_from_node_modules_path(&path),
917 Some("@babel/core".to_string())
918 );
919 }
920
921 #[test]
922 fn test_extract_package_name_from_node_modules_path_nested() {
923 let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
925 assert_eq!(
926 extract_package_name_from_node_modules_path(&path),
927 Some("pkg-b".to_string())
928 );
929 }
930
931 #[test]
932 fn test_extract_package_name_from_node_modules_path_deep_subpath() {
933 let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
934 assert_eq!(
935 extract_package_name_from_node_modules_path(&path),
936 Some("react-dom".to_string())
937 );
938 }
939
940 #[test]
941 fn test_extract_package_name_from_node_modules_path_no_node_modules() {
942 let path = PathBuf::from("/project/src/components/Button.tsx");
943 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
944 }
945
946 #[test]
947 fn test_extract_package_name_from_node_modules_path_just_node_modules() {
948 let path = PathBuf::from("/project/node_modules");
949 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
950 }
951
952 #[test]
953 fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
954 let path = PathBuf::from("/project/node_modules/@scope");
956 assert_eq!(
957 extract_package_name_from_node_modules_path(&path),
958 Some("@scope".to_string())
959 );
960 }
961
962 #[test]
963 fn test_resolve_specifier_node_modules_returns_npm_package() {
964 let path =
970 PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
971 assert_eq!(
972 extract_package_name_from_node_modules_path(&path),
973 Some("styled-components".to_string())
974 );
975
976 let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
977 assert_eq!(
978 extract_package_name_from_node_modules_path(&path),
979 Some("next".to_string())
980 );
981 }
982
983 #[test]
984 fn test_try_source_fallback_dist_to_src() {
985 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
986 let mut path_to_id = FxHashMap::default();
987 path_to_id.insert(src_path.as_path(), FileId(0));
988
989 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
990 assert_eq!(
991 try_source_fallback(&dist_path, &path_to_id),
992 Some(FileId(0)),
993 "dist/utils.js should fall back to src/utils.ts"
994 );
995 }
996
997 #[test]
998 fn test_try_source_fallback_build_to_src() {
999 let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1000 let mut path_to_id = FxHashMap::default();
1001 path_to_id.insert(src_path.as_path(), FileId(1));
1002
1003 let build_path = PathBuf::from("/project/packages/core/build/index.js");
1004 assert_eq!(
1005 try_source_fallback(&build_path, &path_to_id),
1006 Some(FileId(1)),
1007 "build/index.js should fall back to src/index.tsx"
1008 );
1009 }
1010
1011 #[test]
1012 fn test_try_source_fallback_no_match() {
1013 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1014
1015 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1016 assert_eq!(
1017 try_source_fallback(&dist_path, &path_to_id),
1018 None,
1019 "should return None when no source file exists"
1020 );
1021 }
1022
1023 #[test]
1024 fn test_try_source_fallback_non_output_dir() {
1025 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1026 let mut path_to_id = FxHashMap::default();
1027 path_to_id.insert(src_path.as_path(), FileId(0));
1028
1029 let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1031 assert_eq!(
1032 try_source_fallback(&normal_path, &path_to_id),
1033 None,
1034 "non-output directory path should not trigger fallback"
1035 );
1036 }
1037
1038 #[test]
1039 fn test_try_source_fallback_nested_path() {
1040 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1041 let mut path_to_id = FxHashMap::default();
1042 path_to_id.insert(src_path.as_path(), FileId(2));
1043
1044 let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1045 assert_eq!(
1046 try_source_fallback(&dist_path, &path_to_id),
1047 Some(FileId(2)),
1048 "nested dist path should fall back to nested src path"
1049 );
1050 }
1051
1052 #[test]
1053 fn test_try_source_fallback_nested_dist_esm() {
1054 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1055 let mut path_to_id = FxHashMap::default();
1056 path_to_id.insert(src_path.as_path(), FileId(0));
1057
1058 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1059 assert_eq!(
1060 try_source_fallback(&dist_path, &path_to_id),
1061 Some(FileId(0)),
1062 "dist/esm/utils.mjs should fall back to src/utils.ts"
1063 );
1064 }
1065
1066 #[test]
1067 fn test_try_source_fallback_nested_build_cjs() {
1068 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1069 let mut path_to_id = FxHashMap::default();
1070 path_to_id.insert(src_path.as_path(), FileId(1));
1071
1072 let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1073 assert_eq!(
1074 try_source_fallback(&build_path, &path_to_id),
1075 Some(FileId(1)),
1076 "build/cjs/index.cjs should fall back to src/index.ts"
1077 );
1078 }
1079
1080 #[test]
1081 fn test_try_source_fallback_nested_dist_esm_deep_path() {
1082 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1083 let mut path_to_id = FxHashMap::default();
1084 path_to_id.insert(src_path.as_path(), FileId(2));
1085
1086 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1087 assert_eq!(
1088 try_source_fallback(&dist_path, &path_to_id),
1089 Some(FileId(2)),
1090 "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_try_source_fallback_triple_nested_output_dirs() {
1096 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1097 let mut path_to_id = FxHashMap::default();
1098 path_to_id.insert(src_path.as_path(), FileId(0));
1099
1100 let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1101 assert_eq!(
1102 try_source_fallback(&dist_path, &path_to_id),
1103 Some(FileId(0)),
1104 "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1105 );
1106 }
1107
1108 #[test]
1109 fn test_try_source_fallback_parent_dir_named_build() {
1110 let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1111 let mut path_to_id = FxHashMap::default();
1112 path_to_id.insert(src_path.as_path(), FileId(0));
1113
1114 let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1115 assert_eq!(
1116 try_source_fallback(&dist_path, &path_to_id),
1117 Some(FileId(0)),
1118 "should resolve dist/ within project, not match parent 'build' dir"
1119 );
1120 }
1121
1122 #[test]
1123 fn test_pnpm_store_path_extract_package_name() {
1124 let path =
1126 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1127 assert_eq!(
1128 extract_package_name_from_node_modules_path(&path),
1129 Some("react".to_string())
1130 );
1131 }
1132
1133 #[test]
1134 fn test_pnpm_store_path_scoped_package() {
1135 let path = PathBuf::from(
1136 "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1137 );
1138 assert_eq!(
1139 extract_package_name_from_node_modules_path(&path),
1140 Some("@babel/core".to_string())
1141 );
1142 }
1143
1144 #[test]
1145 fn test_pnpm_store_path_with_peer_deps() {
1146 let path = PathBuf::from(
1147 "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
1148 );
1149 assert_eq!(
1150 extract_package_name_from_node_modules_path(&path),
1151 Some("webpack".to_string())
1152 );
1153 }
1154
1155 #[test]
1156 fn test_try_pnpm_workspace_fallback_dist_to_src() {
1157 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1158 let mut path_to_id = FxHashMap::default();
1159 path_to_id.insert(src_path.as_path(), FileId(0));
1160
1161 let mut workspace_roots = FxHashMap::default();
1162 let ws_root = PathBuf::from("/project/packages/ui");
1163 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1164
1165 let pnpm_path = PathBuf::from(
1167 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
1168 );
1169 assert_eq!(
1170 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1171 Some(FileId(0)),
1172 ".pnpm workspace path should fall back to src/utils.ts"
1173 );
1174 }
1175
1176 #[test]
1177 fn test_try_pnpm_workspace_fallback_direct_source() {
1178 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1179 let mut path_to_id = FxHashMap::default();
1180 path_to_id.insert(src_path.as_path(), FileId(1));
1181
1182 let mut workspace_roots = FxHashMap::default();
1183 let ws_root = PathBuf::from("/project/packages/core");
1184 workspace_roots.insert("@myorg/core", ws_root.as_path());
1185
1186 let pnpm_path = PathBuf::from(
1188 "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
1189 );
1190 assert_eq!(
1191 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1192 Some(FileId(1)),
1193 ".pnpm workspace path with src/ should resolve directly"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_try_pnpm_workspace_fallback_non_workspace_package() {
1199 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1200
1201 let mut workspace_roots = FxHashMap::default();
1202 let ws_root = PathBuf::from("/project/packages/ui");
1203 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1204
1205 let pnpm_path =
1207 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1208 assert_eq!(
1209 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1210 None,
1211 "non-workspace package in .pnpm should return None"
1212 );
1213 }
1214
1215 #[test]
1216 fn test_try_pnpm_workspace_fallback_unscoped_package() {
1217 let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
1218 let mut path_to_id = FxHashMap::default();
1219 path_to_id.insert(src_path.as_path(), FileId(2));
1220
1221 let mut workspace_roots = FxHashMap::default();
1222 let ws_root = PathBuf::from("/project/packages/utils");
1223 workspace_roots.insert("my-utils", ws_root.as_path());
1224
1225 let pnpm_path = PathBuf::from(
1227 "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
1228 );
1229 assert_eq!(
1230 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1231 Some(FileId(2)),
1232 "unscoped workspace package in .pnpm should resolve"
1233 );
1234 }
1235
1236 #[test]
1237 fn test_try_pnpm_workspace_fallback_nested_path() {
1238 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1239 let mut path_to_id = FxHashMap::default();
1240 path_to_id.insert(src_path.as_path(), FileId(3));
1241
1242 let mut workspace_roots = FxHashMap::default();
1243 let ws_root = PathBuf::from("/project/packages/ui");
1244 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1245
1246 let pnpm_path = PathBuf::from(
1248 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1249 );
1250 assert_eq!(
1251 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1252 Some(FileId(3)),
1253 "nested .pnpm workspace path should resolve through source fallback"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_try_pnpm_workspace_fallback_no_pnpm() {
1259 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1260 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1261
1262 let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1264 assert_eq!(
1265 try_pnpm_workspace_fallback(®ular_path, &path_to_id, &workspace_roots),
1266 None,
1267 );
1268 }
1269
1270 #[test]
1271 fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1272 let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1273 let mut path_to_id = FxHashMap::default();
1274 path_to_id.insert(src_path.as_path(), FileId(4));
1275
1276 let mut workspace_roots = FxHashMap::default();
1277 let ws_root = PathBuf::from("/project/packages/ui");
1278 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1279
1280 let pnpm_path = PathBuf::from(
1282 "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1283 );
1284 assert_eq!(
1285 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1286 Some(FileId(4)),
1287 ".pnpm path with peer dep suffix should still resolve"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_has_react_native_plugin_active() {
1293 let plugins = vec!["react-native".to_string(), "typescript".to_string()];
1294 assert!(has_react_native_plugin(&plugins));
1295 }
1296
1297 #[test]
1298 fn test_has_expo_plugin_active() {
1299 let plugins = vec!["expo".to_string(), "typescript".to_string()];
1300 assert!(has_react_native_plugin(&plugins));
1301 }
1302
1303 #[test]
1304 fn test_has_react_native_plugin_inactive() {
1305 let plugins = vec!["nextjs".to_string(), "typescript".to_string()];
1306 assert!(!has_react_native_plugin(&plugins));
1307 }
1308
1309 #[test]
1310 fn test_rn_platform_extensions_prepended() {
1311 let no_rn = build_extensions(&[]);
1312 let rn_plugins = vec!["react-native".to_string()];
1313 let with_rn = build_extensions(&rn_plugins);
1314
1315 assert_eq!(no_rn[0], ".ts");
1317
1318 assert_eq!(with_rn[0], ".web.ts");
1320 assert_eq!(with_rn[1], ".web.tsx");
1321 assert_eq!(with_rn[2], ".web.js");
1322 assert_eq!(with_rn[3], ".web.jsx");
1323
1324 assert!(with_rn.len() > no_rn.len());
1326 assert_eq!(
1327 with_rn.len(),
1328 no_rn.len() + 16,
1329 "should add 16 platform extensions (4 platforms x 4 exts)"
1330 );
1331 }
1332
1333 #[test]
1334 fn test_rn_condition_names_prepended() {
1335 let no_rn = build_condition_names(&[]);
1336 let rn_plugins = vec!["react-native".to_string()];
1337 let with_rn = build_condition_names(&rn_plugins);
1338
1339 assert_eq!(no_rn[0], "import");
1341
1342 assert_eq!(with_rn[0], "react-native");
1344 assert_eq!(with_rn[1], "browser");
1345 assert_eq!(with_rn[2], "import");
1346 }
1347
1348 mod proptests {
1349 use super::*;
1350 use proptest::prelude::*;
1351
1352 proptest! {
1353 #[test]
1355 fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1356 let dot = format!(".{suffix}");
1357 let slash = format!("/{suffix}");
1358 prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
1359 prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
1360 }
1361
1362 #[test]
1364 fn scoped_package_name_has_two_segments(
1365 scope in "[a-z][a-z0-9-]{0,20}",
1366 pkg in "[a-z][a-z0-9-]{0,20}",
1367 subpath in "(/[a-z0-9-]{1,20}){0,3}",
1368 ) {
1369 let specifier = format!("@{scope}/{pkg}{subpath}");
1370 let extracted = extract_package_name(&specifier);
1371 let expected = format!("@{scope}/{pkg}");
1372 prop_assert_eq!(extracted, expected);
1373 }
1374
1375 #[test]
1377 fn unscoped_package_name_is_first_segment(
1378 pkg in "[a-z][a-z0-9-]{0,30}",
1379 subpath in "(/[a-z0-9-]{1,20}){0,3}",
1380 ) {
1381 let specifier = format!("{pkg}{subpath}");
1382 let extracted = extract_package_name(&specifier);
1383 prop_assert_eq!(extracted, pkg);
1384 }
1385
1386 #[test]
1388 fn bare_specifier_and_path_alias_no_panic(s in "[a-zA-Z0-9@#~/._-]{1,100}") {
1389 let _ = is_bare_specifier(&s);
1390 let _ = is_path_alias(&s);
1391 }
1392
1393 #[test]
1395 fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1396 let specifier = format!("@/{suffix}");
1397 prop_assert!(is_path_alias(&specifier));
1398 }
1399
1400 #[test]
1402 fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1403 let specifier = format!("~/{suffix}");
1404 prop_assert!(is_path_alias(&specifier));
1405 }
1406
1407 #[test]
1409 fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1410 let specifier = format!("#{suffix}");
1411 prop_assert!(is_path_alias(&specifier));
1412 }
1413
1414 #[test]
1416 fn node_modules_package_name_never_empty(
1417 pkg in "[a-z][a-z0-9-]{0,20}",
1418 file in "[a-z]{1,10}\\.(js|ts|mjs)",
1419 ) {
1420 let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
1421 if let Some(name) = extract_package_name_from_node_modules_path(&path) {
1422 prop_assert!(!name.is_empty());
1423 }
1424 }
1425 }
1426 }
1427}