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}
99
100pub fn resolve_all_imports(
102 modules: &[ModuleInfo],
103 files: &[DiscoveredFile],
104 workspaces: &[fallow_config::WorkspaceInfo],
105 active_plugins: &[String],
106 path_aliases: &[(String, String)],
107 root: &Path,
108) -> Vec<ResolvedModule> {
109 let canonical_ws_roots: Vec<PathBuf> = workspaces
114 .par_iter()
115 .map(|ws| ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone()))
116 .collect();
117 let workspace_roots: FxHashMap<&str, &Path> = workspaces
118 .iter()
119 .zip(canonical_ws_roots.iter())
120 .map(|(ws, canonical)| (ws.name.as_str(), canonical.as_path()))
121 .collect();
122
123 let canonical_paths: Vec<PathBuf> = files
126 .par_iter()
127 .map(|f| f.path.canonicalize().unwrap_or_else(|_| f.path.clone()))
128 .collect();
129
130 let path_to_id: FxHashMap<&Path, FileId> = canonical_paths
132 .iter()
133 .enumerate()
134 .map(|(idx, canonical)| (canonical.as_path(), files[idx].id))
135 .collect();
136
137 let raw_path_to_id: FxHashMap<&Path, FileId> =
139 files.iter().map(|f| (f.path.as_path(), f.id)).collect();
140
141 let file_paths: Vec<&Path> = files.iter().map(|f| f.path.as_path()).collect();
143
144 let resolver = create_resolver(active_plugins);
146
147 let bare_cache = BareSpecifierCache::new();
149
150 modules
152 .par_iter()
153 .filter_map(|module| {
154 let Some(file_path) = file_paths.get(module.file_id.0 as usize) else {
155 tracing::warn!(
156 file_id = module.file_id.0,
157 "Skipping module with unknown file_id during resolution"
158 );
159 return None;
160 };
161
162 let resolved_imports: Vec<ResolvedImport> = module
163 .imports
164 .iter()
165 .map(|imp| ResolvedImport {
166 info: imp.clone(),
167 target: resolve_specifier(
168 &resolver,
169 file_path,
170 &imp.source,
171 &path_to_id,
172 &raw_path_to_id,
173 &bare_cache,
174 &workspace_roots,
175 path_aliases,
176 root,
177 ),
178 })
179 .collect();
180
181 let resolved_dynamic_imports: Vec<ResolvedImport> = module
182 .dynamic_imports
183 .iter()
184 .flat_map(|imp| {
185 let target = resolve_specifier(
186 &resolver,
187 file_path,
188 &imp.source,
189 &path_to_id,
190 &raw_path_to_id,
191 &bare_cache,
192 &workspace_roots,
193 path_aliases,
194 root,
195 );
196 if !imp.destructured_names.is_empty() {
197 imp.destructured_names
199 .iter()
200 .map(|name| ResolvedImport {
201 info: ImportInfo {
202 source: imp.source.clone(),
203 imported_name: fallow_types::extract::ImportedName::Named(
204 name.clone(),
205 ),
206 local_name: name.clone(),
207 is_type_only: false,
208 span: imp.span,
209 },
210 target: target.clone(),
211 })
212 .collect()
213 } else if imp.local_name.is_some() {
214 vec![ResolvedImport {
216 info: ImportInfo {
217 source: imp.source.clone(),
218 imported_name: fallow_types::extract::ImportedName::Namespace,
219 local_name: imp.local_name.clone().unwrap_or_default(),
220 is_type_only: false,
221 span: imp.span,
222 },
223 target,
224 }]
225 } else {
226 vec![ResolvedImport {
228 info: ImportInfo {
229 source: imp.source.clone(),
230 imported_name: fallow_types::extract::ImportedName::SideEffect,
231 local_name: String::new(),
232 is_type_only: false,
233 span: imp.span,
234 },
235 target,
236 }]
237 }
238 })
239 .collect();
240
241 let re_exports: Vec<ResolvedReExport> = module
242 .re_exports
243 .iter()
244 .map(|re| ResolvedReExport {
245 info: re.clone(),
246 target: resolve_specifier(
247 &resolver,
248 file_path,
249 &re.source,
250 &path_to_id,
251 &raw_path_to_id,
252 &bare_cache,
253 &workspace_roots,
254 path_aliases,
255 root,
256 ),
257 })
258 .collect();
259
260 let require_imports: Vec<ResolvedImport> = module
263 .require_calls
264 .iter()
265 .flat_map(|req| {
266 let target = resolve_specifier(
267 &resolver,
268 file_path,
269 &req.source,
270 &path_to_id,
271 &raw_path_to_id,
272 &bare_cache,
273 &workspace_roots,
274 path_aliases,
275 root,
276 );
277 if req.destructured_names.is_empty() {
278 vec![ResolvedImport {
279 info: ImportInfo {
280 source: req.source.clone(),
281 imported_name: fallow_types::extract::ImportedName::Namespace,
282 local_name: req.local_name.clone().unwrap_or_default(),
283 is_type_only: false,
284 span: req.span,
285 },
286 target,
287 }]
288 } else {
289 req.destructured_names
290 .iter()
291 .map(|name| ResolvedImport {
292 info: ImportInfo {
293 source: req.source.clone(),
294 imported_name: fallow_types::extract::ImportedName::Named(
295 name.clone(),
296 ),
297 local_name: name.clone(),
298 is_type_only: false,
299 span: req.span,
300 },
301 target: target.clone(),
302 })
303 .collect()
304 }
305 })
306 .collect();
307
308 let mut all_imports = resolved_imports;
309 all_imports.extend(require_imports);
310
311 let from_dir = canonical_paths
314 .get(module.file_id.0 as usize)
315 .and_then(|p| p.parent())
316 .unwrap_or(file_path);
317 let resolved_dynamic_patterns: Vec<(
318 fallow_types::extract::DynamicImportPattern,
319 Vec<FileId>,
320 )> = module
321 .dynamic_import_patterns
322 .iter()
323 .filter_map(|pattern| {
324 let glob_str = make_glob_from_pattern(pattern);
325 let matcher = globset::Glob::new(&glob_str)
326 .ok()
327 .map(|g| g.compile_matcher())?;
328 let matched: Vec<FileId> = canonical_paths
329 .iter()
330 .enumerate()
331 .filter(|(_idx, canonical)| {
332 canonical.strip_prefix(from_dir).is_ok_and(|relative| {
333 let rel_str = format!("./{}", relative.to_string_lossy());
334 matcher.is_match(&rel_str)
335 })
336 })
337 .map(|(idx, _)| files[idx].id)
338 .collect();
339 if matched.is_empty() {
340 None
341 } else {
342 Some((pattern.clone(), matched))
343 }
344 })
345 .collect();
346
347 Some(ResolvedModule {
348 file_id: module.file_id,
349 path: file_path.to_path_buf(),
350 exports: module.exports.clone(),
351 re_exports,
352 resolved_imports: all_imports,
353 resolved_dynamic_imports,
354 resolved_dynamic_patterns,
355 member_accesses: module.member_accesses.clone(),
356 whole_object_uses: module.whole_object_uses.clone(),
357 has_cjs_exports: module.has_cjs_exports,
358 })
359 })
360 .collect()
361}
362
363pub fn is_path_alias(specifier: &str) -> bool {
370 if specifier.starts_with('#') {
372 return true;
373 }
374 if specifier.starts_with("~/") || specifier.starts_with("~~/") {
376 return true;
377 }
378 if specifier.starts_with("@/") {
380 return true;
381 }
382 if specifier.starts_with('@') {
386 let scope = specifier.split('/').next().unwrap_or(specifier);
387 if scope.len() > 1 && scope.chars().nth(1).is_some_and(|c| c.is_ascii_uppercase()) {
388 return true;
389 }
390 }
391
392 false
393}
394
395const RN_PLATFORM_PREFIXES: &[&str] = &[".web", ".ios", ".android", ".native"];
398
399fn has_react_native_plugin(active_plugins: &[String]) -> bool {
401 active_plugins
402 .iter()
403 .any(|p| p == "react-native" || p == "expo")
404}
405
406fn build_extensions(active_plugins: &[String]) -> Vec<String> {
409 let base: Vec<String> = vec![
410 ".ts".into(),
411 ".tsx".into(),
412 ".d.ts".into(),
413 ".d.mts".into(),
414 ".d.cts".into(),
415 ".mts".into(),
416 ".cts".into(),
417 ".js".into(),
418 ".jsx".into(),
419 ".mjs".into(),
420 ".cjs".into(),
421 ".json".into(),
422 ".vue".into(),
423 ".svelte".into(),
424 ".astro".into(),
425 ".mdx".into(),
426 ".css".into(),
427 ".scss".into(),
428 ];
429
430 if has_react_native_plugin(active_plugins) {
431 let source_exts = [".ts", ".tsx", ".js", ".jsx"];
432 let mut rn_extensions: Vec<String> = Vec::new();
433 for platform in RN_PLATFORM_PREFIXES {
434 for ext in &source_exts {
435 rn_extensions.push(format!("{platform}{ext}"));
436 }
437 }
438 rn_extensions.extend(base);
439 rn_extensions
440 } else {
441 base
442 }
443}
444
445fn build_condition_names(active_plugins: &[String]) -> Vec<String> {
448 let mut names = vec![
449 "import".into(),
450 "require".into(),
451 "default".into(),
452 "types".into(),
453 "node".into(),
454 ];
455 if has_react_native_plugin(active_plugins) {
456 names.insert(0, "react-native".into());
457 names.insert(1, "browser".into());
458 }
459 names
460}
461
462fn create_resolver(active_plugins: &[String]) -> Resolver {
468 let mut options = ResolveOptions {
469 extensions: build_extensions(active_plugins),
470 extension_alias: vec![
473 (
474 ".js".into(),
475 vec![".ts".into(), ".tsx".into(), ".js".into()],
476 ),
477 (".jsx".into(), vec![".tsx".into(), ".jsx".into()]),
478 (".mjs".into(), vec![".mts".into(), ".mjs".into()]),
479 (".cjs".into(), vec![".cts".into(), ".cjs".into()]),
480 ],
481 condition_names: build_condition_names(active_plugins),
482 main_fields: vec!["module".into(), "main".into()],
483 ..Default::default()
484 };
485
486 options.tsconfig = Some(oxc_resolver::TsconfigDiscovery::Auto);
494
495 Resolver::new(options)
496}
497
498#[expect(clippy::too_many_arguments)]
500fn resolve_specifier(
501 resolver: &Resolver,
502 from_file: &Path,
503 specifier: &str,
504 path_to_id: &FxHashMap<&Path, FileId>,
505 raw_path_to_id: &FxHashMap<&Path, FileId>,
506 bare_cache: &BareSpecifierCache,
507 workspace_roots: &FxHashMap<&str, &Path>,
508 path_aliases: &[(String, String)],
509 root: &Path,
510) -> ResolveResult {
511 if specifier.contains("://") || specifier.starts_with("data:") {
513 return ResolveResult::ExternalFile(PathBuf::from(specifier));
514 }
515
516 let is_bare = is_bare_specifier(specifier);
520 let is_alias = is_path_alias(specifier);
521 if is_bare
522 && !is_alias
523 && let Some(cached) = bare_cache.get(specifier)
524 {
525 return cached;
526 }
527
528 let result = match resolver.resolve_file(from_file, specifier) {
533 Ok(resolved) => {
534 let resolved_path = resolved.path();
535 if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
537 return ResolveResult::InternalModule(file_id);
538 }
539 match resolved_path.canonicalize() {
541 Ok(canonical) => {
542 if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
543 ResolveResult::InternalModule(file_id)
544 } else if let Some(file_id) = try_source_fallback(&canonical, path_to_id) {
545 ResolveResult::InternalModule(file_id)
548 } else if let Some(file_id) =
549 try_pnpm_workspace_fallback(&canonical, path_to_id, workspace_roots)
550 {
551 ResolveResult::InternalModule(file_id)
552 } else if let Some(pkg_name) =
553 extract_package_name_from_node_modules_path(&canonical)
554 {
555 ResolveResult::NpmPackage(pkg_name)
556 } else {
557 ResolveResult::ExternalFile(canonical)
558 }
559 }
560 Err(_) => {
561 if let Some(file_id) = try_source_fallback(resolved_path, path_to_id) {
563 ResolveResult::InternalModule(file_id)
564 } else if let Some(file_id) =
565 try_pnpm_workspace_fallback(resolved_path, path_to_id, workspace_roots)
566 {
567 ResolveResult::InternalModule(file_id)
568 } else if let Some(pkg_name) =
569 extract_package_name_from_node_modules_path(resolved_path)
570 {
571 ResolveResult::NpmPackage(pkg_name)
572 } else {
573 ResolveResult::ExternalFile(resolved_path.to_path_buf())
574 }
575 }
576 }
577 }
578 Err(_) => {
579 if is_alias {
580 if let Some(resolved) = try_path_alias_fallback(
584 resolver,
585 specifier,
586 path_aliases,
587 root,
588 path_to_id,
589 raw_path_to_id,
590 workspace_roots,
591 ) {
592 resolved
593 } else {
594 ResolveResult::Unresolvable(specifier.to_string())
597 }
598 } else if is_bare {
599 let pkg_name = extract_package_name(specifier);
600 ResolveResult::NpmPackage(pkg_name)
601 } else {
602 ResolveResult::Unresolvable(specifier.to_string())
603 }
604 }
605 };
606
607 if is_bare && !is_alias {
610 bare_cache.insert(specifier.to_string(), result.clone());
611 }
612
613 result
614}
615
616fn try_path_alias_fallback(
623 resolver: &Resolver,
624 specifier: &str,
625 path_aliases: &[(String, String)],
626 root: &Path,
627 path_to_id: &FxHashMap<&Path, FileId>,
628 raw_path_to_id: &FxHashMap<&Path, FileId>,
629 workspace_roots: &FxHashMap<&str, &Path>,
630) -> Option<ResolveResult> {
631 for (prefix, replacement) in path_aliases {
632 if !specifier.starts_with(prefix.as_str()) {
633 continue;
634 }
635
636 let remainder = &specifier[prefix.len()..];
637 let substituted = if replacement.is_empty() {
640 format!("./{remainder}")
641 } else {
642 format!("./{replacement}/{remainder}")
643 };
644
645 let root_file = root.join("__resolve_root__");
648 if let Ok(resolved) = resolver.resolve_file(&root_file, &substituted) {
649 let resolved_path = resolved.path();
650 if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
652 return Some(ResolveResult::InternalModule(file_id));
653 }
654 if let Ok(canonical) = resolved_path.canonicalize() {
656 if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
657 return Some(ResolveResult::InternalModule(file_id));
658 }
659 if let Some(file_id) = try_source_fallback(&canonical, path_to_id) {
660 return Some(ResolveResult::InternalModule(file_id));
661 }
662 if let Some(file_id) =
663 try_pnpm_workspace_fallback(&canonical, path_to_id, workspace_roots)
664 {
665 return Some(ResolveResult::InternalModule(file_id));
666 }
667 if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
668 return Some(ResolveResult::NpmPackage(pkg_name));
669 }
670 return Some(ResolveResult::ExternalFile(canonical));
671 }
672 }
673 }
674 None
675}
676
677const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
682
683const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
685
686fn try_source_fallback(resolved: &Path, path_to_id: &FxHashMap<&Path, FileId>) -> Option<FileId> {
698 let components: Vec<_> = resolved.components().collect();
699
700 let is_output_dir = |c: &std::path::Component| -> bool {
701 if let std::path::Component::Normal(s) = c
702 && let Some(name) = s.to_str()
703 {
704 return OUTPUT_DIRS.contains(&name);
705 }
706 false
707 };
708
709 let last_output_pos = components.iter().rposition(&is_output_dir)?;
713
714 let mut first_output_pos = last_output_pos;
717 while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
718 first_output_pos -= 1;
719 }
720
721 let prefix: PathBuf = components[..first_output_pos].iter().collect();
723
724 let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
726 suffix.file_stem()?; for ext in SOURCE_EXTS {
730 let source_candidate = prefix.join("src").join(suffix.with_extension(ext));
731 if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
732 return Some(file_id);
733 }
734 }
735
736 None
737}
738
739fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
745 let components: Vec<&str> = path
746 .components()
747 .filter_map(|c| match c {
748 std::path::Component::Normal(s) => s.to_str(),
749 _ => None,
750 })
751 .collect();
752
753 let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
755
756 let after = &components[nm_idx + 1..];
757 if after.is_empty() {
758 return None;
759 }
760
761 if after[0].starts_with('@') {
762 if after.len() >= 2 {
764 Some(format!("{}/{}", after[0], after[1]))
765 } else {
766 Some(after[0].to_string())
767 }
768 } else {
769 Some(after[0].to_string())
770 }
771}
772
773fn try_pnpm_workspace_fallback(
782 path: &Path,
783 path_to_id: &FxHashMap<&Path, FileId>,
784 workspace_roots: &FxHashMap<&str, &Path>,
785) -> Option<FileId> {
786 let components: Vec<&str> = path
788 .components()
789 .filter_map(|c| match c {
790 std::path::Component::Normal(s) => s.to_str(),
791 _ => None,
792 })
793 .collect();
794
795 let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
797
798 let after_pnpm = &components[pnpm_idx + 1..];
801
802 let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
804 let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
805
806 if after_inner_nm.is_empty() {
807 return None;
808 }
809
810 let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
812 if after_inner_nm.len() >= 2 {
813 (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
814 } else {
815 return None;
816 }
817 } else {
818 (after_inner_nm[0].to_string(), 1)
819 };
820
821 let ws_root = workspace_roots.get(pkg_name.as_str())?;
823
824 let relative_parts = &after_inner_nm[pkg_name_components..];
826 if relative_parts.is_empty() {
827 return None;
828 }
829
830 let relative_path: PathBuf = relative_parts.iter().collect();
831
832 let direct = ws_root.join(&relative_path);
834 if let Some(&file_id) = path_to_id.get(direct.as_path()) {
835 return Some(file_id);
836 }
837
838 try_source_fallback(&direct, path_to_id)
840}
841
842fn make_glob_from_pattern(pattern: &fallow_types::extract::DynamicImportPattern) -> String {
844 if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
846 return pattern.prefix.clone();
847 }
848 pattern.suffix.as_ref().map_or_else(
849 || format!("{}*", pattern.prefix),
850 |suffix| format!("{}*{}", pattern.prefix, suffix),
851 )
852}
853
854fn is_bare_specifier(specifier: &str) -> bool {
856 !specifier.starts_with('.')
857 && !specifier.starts_with('/')
858 && !specifier.contains("://")
859 && !specifier.starts_with("data:")
860}
861
862pub fn extract_package_name(specifier: &str) -> String {
866 if specifier.starts_with('@') {
867 let parts: Vec<&str> = specifier.splitn(3, '/').collect();
868 if parts.len() >= 2 {
869 format!("{}/{}", parts[0], parts[1])
870 } else {
871 specifier.to_string()
872 }
873 } else {
874 specifier.split('/').next().unwrap_or(specifier).to_string()
875 }
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881
882 #[test]
883 fn test_extract_package_name() {
884 assert_eq!(extract_package_name("react"), "react");
885 assert_eq!(extract_package_name("lodash/merge"), "lodash");
886 assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
887 assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
888 }
889
890 #[test]
891 fn test_is_bare_specifier() {
892 assert!(is_bare_specifier("react"));
893 assert!(is_bare_specifier("@scope/pkg"));
894 assert!(is_bare_specifier("#internal/module"));
895 assert!(!is_bare_specifier("./utils"));
896 assert!(!is_bare_specifier("../lib"));
897 assert!(!is_bare_specifier("/absolute"));
898 }
899
900 #[test]
901 fn test_extract_package_name_from_node_modules_path_regular() {
902 let path = PathBuf::from("/project/node_modules/react/index.js");
903 assert_eq!(
904 extract_package_name_from_node_modules_path(&path),
905 Some("react".to_string())
906 );
907 }
908
909 #[test]
910 fn test_extract_package_name_from_node_modules_path_scoped() {
911 let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
912 assert_eq!(
913 extract_package_name_from_node_modules_path(&path),
914 Some("@babel/core".to_string())
915 );
916 }
917
918 #[test]
919 fn test_extract_package_name_from_node_modules_path_nested() {
920 let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
922 assert_eq!(
923 extract_package_name_from_node_modules_path(&path),
924 Some("pkg-b".to_string())
925 );
926 }
927
928 #[test]
929 fn test_extract_package_name_from_node_modules_path_deep_subpath() {
930 let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
931 assert_eq!(
932 extract_package_name_from_node_modules_path(&path),
933 Some("react-dom".to_string())
934 );
935 }
936
937 #[test]
938 fn test_extract_package_name_from_node_modules_path_no_node_modules() {
939 let path = PathBuf::from("/project/src/components/Button.tsx");
940 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
941 }
942
943 #[test]
944 fn test_extract_package_name_from_node_modules_path_just_node_modules() {
945 let path = PathBuf::from("/project/node_modules");
946 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
947 }
948
949 #[test]
950 fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
951 let path = PathBuf::from("/project/node_modules/@scope");
953 assert_eq!(
954 extract_package_name_from_node_modules_path(&path),
955 Some("@scope".to_string())
956 );
957 }
958
959 #[test]
960 fn test_resolve_specifier_node_modules_returns_npm_package() {
961 let path =
967 PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
968 assert_eq!(
969 extract_package_name_from_node_modules_path(&path),
970 Some("styled-components".to_string())
971 );
972
973 let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
974 assert_eq!(
975 extract_package_name_from_node_modules_path(&path),
976 Some("next".to_string())
977 );
978 }
979
980 #[test]
981 fn test_try_source_fallback_dist_to_src() {
982 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
983 let mut path_to_id = FxHashMap::default();
984 path_to_id.insert(src_path.as_path(), FileId(0));
985
986 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
987 assert_eq!(
988 try_source_fallback(&dist_path, &path_to_id),
989 Some(FileId(0)),
990 "dist/utils.js should fall back to src/utils.ts"
991 );
992 }
993
994 #[test]
995 fn test_try_source_fallback_build_to_src() {
996 let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
997 let mut path_to_id = FxHashMap::default();
998 path_to_id.insert(src_path.as_path(), FileId(1));
999
1000 let build_path = PathBuf::from("/project/packages/core/build/index.js");
1001 assert_eq!(
1002 try_source_fallback(&build_path, &path_to_id),
1003 Some(FileId(1)),
1004 "build/index.js should fall back to src/index.tsx"
1005 );
1006 }
1007
1008 #[test]
1009 fn test_try_source_fallback_no_match() {
1010 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1011
1012 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1013 assert_eq!(
1014 try_source_fallback(&dist_path, &path_to_id),
1015 None,
1016 "should return None when no source file exists"
1017 );
1018 }
1019
1020 #[test]
1021 fn test_try_source_fallback_non_output_dir() {
1022 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1023 let mut path_to_id = FxHashMap::default();
1024 path_to_id.insert(src_path.as_path(), FileId(0));
1025
1026 let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1028 assert_eq!(
1029 try_source_fallback(&normal_path, &path_to_id),
1030 None,
1031 "non-output directory path should not trigger fallback"
1032 );
1033 }
1034
1035 #[test]
1036 fn test_try_source_fallback_nested_path() {
1037 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1038 let mut path_to_id = FxHashMap::default();
1039 path_to_id.insert(src_path.as_path(), FileId(2));
1040
1041 let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1042 assert_eq!(
1043 try_source_fallback(&dist_path, &path_to_id),
1044 Some(FileId(2)),
1045 "nested dist path should fall back to nested src path"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_try_source_fallback_nested_dist_esm() {
1051 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1052 let mut path_to_id = FxHashMap::default();
1053 path_to_id.insert(src_path.as_path(), FileId(0));
1054
1055 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1056 assert_eq!(
1057 try_source_fallback(&dist_path, &path_to_id),
1058 Some(FileId(0)),
1059 "dist/esm/utils.mjs should fall back to src/utils.ts"
1060 );
1061 }
1062
1063 #[test]
1064 fn test_try_source_fallback_nested_build_cjs() {
1065 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1066 let mut path_to_id = FxHashMap::default();
1067 path_to_id.insert(src_path.as_path(), FileId(1));
1068
1069 let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1070 assert_eq!(
1071 try_source_fallback(&build_path, &path_to_id),
1072 Some(FileId(1)),
1073 "build/cjs/index.cjs should fall back to src/index.ts"
1074 );
1075 }
1076
1077 #[test]
1078 fn test_try_source_fallback_nested_dist_esm_deep_path() {
1079 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1080 let mut path_to_id = FxHashMap::default();
1081 path_to_id.insert(src_path.as_path(), FileId(2));
1082
1083 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1084 assert_eq!(
1085 try_source_fallback(&dist_path, &path_to_id),
1086 Some(FileId(2)),
1087 "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1088 );
1089 }
1090
1091 #[test]
1092 fn test_try_source_fallback_triple_nested_output_dirs() {
1093 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1094 let mut path_to_id = FxHashMap::default();
1095 path_to_id.insert(src_path.as_path(), FileId(0));
1096
1097 let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1098 assert_eq!(
1099 try_source_fallback(&dist_path, &path_to_id),
1100 Some(FileId(0)),
1101 "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1102 );
1103 }
1104
1105 #[test]
1106 fn test_try_source_fallback_parent_dir_named_build() {
1107 let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1108 let mut path_to_id = FxHashMap::default();
1109 path_to_id.insert(src_path.as_path(), FileId(0));
1110
1111 let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1112 assert_eq!(
1113 try_source_fallback(&dist_path, &path_to_id),
1114 Some(FileId(0)),
1115 "should resolve dist/ within project, not match parent 'build' dir"
1116 );
1117 }
1118
1119 #[test]
1120 fn test_pnpm_store_path_extract_package_name() {
1121 let path =
1123 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1124 assert_eq!(
1125 extract_package_name_from_node_modules_path(&path),
1126 Some("react".to_string())
1127 );
1128 }
1129
1130 #[test]
1131 fn test_pnpm_store_path_scoped_package() {
1132 let path = PathBuf::from(
1133 "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1134 );
1135 assert_eq!(
1136 extract_package_name_from_node_modules_path(&path),
1137 Some("@babel/core".to_string())
1138 );
1139 }
1140
1141 #[test]
1142 fn test_pnpm_store_path_with_peer_deps() {
1143 let path = PathBuf::from(
1144 "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
1145 );
1146 assert_eq!(
1147 extract_package_name_from_node_modules_path(&path),
1148 Some("webpack".to_string())
1149 );
1150 }
1151
1152 #[test]
1153 fn test_try_pnpm_workspace_fallback_dist_to_src() {
1154 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1155 let mut path_to_id = FxHashMap::default();
1156 path_to_id.insert(src_path.as_path(), FileId(0));
1157
1158 let mut workspace_roots = FxHashMap::default();
1159 let ws_root = PathBuf::from("/project/packages/ui");
1160 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1161
1162 let pnpm_path = PathBuf::from(
1164 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
1165 );
1166 assert_eq!(
1167 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1168 Some(FileId(0)),
1169 ".pnpm workspace path should fall back to src/utils.ts"
1170 );
1171 }
1172
1173 #[test]
1174 fn test_try_pnpm_workspace_fallback_direct_source() {
1175 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1176 let mut path_to_id = FxHashMap::default();
1177 path_to_id.insert(src_path.as_path(), FileId(1));
1178
1179 let mut workspace_roots = FxHashMap::default();
1180 let ws_root = PathBuf::from("/project/packages/core");
1181 workspace_roots.insert("@myorg/core", ws_root.as_path());
1182
1183 let pnpm_path = PathBuf::from(
1185 "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
1186 );
1187 assert_eq!(
1188 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1189 Some(FileId(1)),
1190 ".pnpm workspace path with src/ should resolve directly"
1191 );
1192 }
1193
1194 #[test]
1195 fn test_try_pnpm_workspace_fallback_non_workspace_package() {
1196 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1197
1198 let mut workspace_roots = FxHashMap::default();
1199 let ws_root = PathBuf::from("/project/packages/ui");
1200 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1201
1202 let pnpm_path =
1204 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1205 assert_eq!(
1206 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1207 None,
1208 "non-workspace package in .pnpm should return None"
1209 );
1210 }
1211
1212 #[test]
1213 fn test_try_pnpm_workspace_fallback_unscoped_package() {
1214 let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
1215 let mut path_to_id = FxHashMap::default();
1216 path_to_id.insert(src_path.as_path(), FileId(2));
1217
1218 let mut workspace_roots = FxHashMap::default();
1219 let ws_root = PathBuf::from("/project/packages/utils");
1220 workspace_roots.insert("my-utils", ws_root.as_path());
1221
1222 let pnpm_path = PathBuf::from(
1224 "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
1225 );
1226 assert_eq!(
1227 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1228 Some(FileId(2)),
1229 "unscoped workspace package in .pnpm should resolve"
1230 );
1231 }
1232
1233 #[test]
1234 fn test_try_pnpm_workspace_fallback_nested_path() {
1235 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1236 let mut path_to_id = FxHashMap::default();
1237 path_to_id.insert(src_path.as_path(), FileId(3));
1238
1239 let mut workspace_roots = FxHashMap::default();
1240 let ws_root = PathBuf::from("/project/packages/ui");
1241 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1242
1243 let pnpm_path = PathBuf::from(
1245 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1246 );
1247 assert_eq!(
1248 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1249 Some(FileId(3)),
1250 "nested .pnpm workspace path should resolve through source fallback"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_try_pnpm_workspace_fallback_no_pnpm() {
1256 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1257 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1258
1259 let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1261 assert_eq!(
1262 try_pnpm_workspace_fallback(®ular_path, &path_to_id, &workspace_roots),
1263 None,
1264 );
1265 }
1266
1267 #[test]
1268 fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1269 let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1270 let mut path_to_id = FxHashMap::default();
1271 path_to_id.insert(src_path.as_path(), FileId(4));
1272
1273 let mut workspace_roots = FxHashMap::default();
1274 let ws_root = PathBuf::from("/project/packages/ui");
1275 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1276
1277 let pnpm_path = PathBuf::from(
1279 "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1280 );
1281 assert_eq!(
1282 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1283 Some(FileId(4)),
1284 ".pnpm path with peer dep suffix should still resolve"
1285 );
1286 }
1287
1288 #[test]
1289 fn test_has_react_native_plugin_active() {
1290 let plugins = vec!["react-native".to_string(), "typescript".to_string()];
1291 assert!(has_react_native_plugin(&plugins));
1292 }
1293
1294 #[test]
1295 fn test_has_expo_plugin_active() {
1296 let plugins = vec!["expo".to_string(), "typescript".to_string()];
1297 assert!(has_react_native_plugin(&plugins));
1298 }
1299
1300 #[test]
1301 fn test_has_react_native_plugin_inactive() {
1302 let plugins = vec!["nextjs".to_string(), "typescript".to_string()];
1303 assert!(!has_react_native_plugin(&plugins));
1304 }
1305
1306 #[test]
1307 fn test_rn_platform_extensions_prepended() {
1308 let no_rn = build_extensions(&[]);
1309 let rn_plugins = vec!["react-native".to_string()];
1310 let with_rn = build_extensions(&rn_plugins);
1311
1312 assert_eq!(no_rn[0], ".ts");
1314
1315 assert_eq!(with_rn[0], ".web.ts");
1317 assert_eq!(with_rn[1], ".web.tsx");
1318 assert_eq!(with_rn[2], ".web.js");
1319 assert_eq!(with_rn[3], ".web.jsx");
1320
1321 assert!(with_rn.len() > no_rn.len());
1323 assert_eq!(
1324 with_rn.len(),
1325 no_rn.len() + 16,
1326 "should add 16 platform extensions (4 platforms x 4 exts)"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_rn_condition_names_prepended() {
1332 let no_rn = build_condition_names(&[]);
1333 let rn_plugins = vec!["react-native".to_string()];
1334 let with_rn = build_condition_names(&rn_plugins);
1335
1336 assert_eq!(no_rn[0], "import");
1338
1339 assert_eq!(with_rn[0], "react-native");
1341 assert_eq!(with_rn[1], "browser");
1342 assert_eq!(with_rn[2], "import");
1343 }
1344
1345 mod proptests {
1346 use super::*;
1347 use proptest::prelude::*;
1348
1349 proptest! {
1350 #[test]
1352 fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1353 let dot = format!(".{suffix}");
1354 let slash = format!("/{suffix}");
1355 prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
1356 prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
1357 }
1358
1359 #[test]
1361 fn scoped_package_name_has_two_segments(
1362 scope in "[a-z][a-z0-9-]{0,20}",
1363 pkg in "[a-z][a-z0-9-]{0,20}",
1364 subpath in "(/[a-z0-9-]{1,20}){0,3}",
1365 ) {
1366 let specifier = format!("@{scope}/{pkg}{subpath}");
1367 let extracted = extract_package_name(&specifier);
1368 let expected = format!("@{scope}/{pkg}");
1369 prop_assert_eq!(extracted, expected);
1370 }
1371
1372 #[test]
1374 fn unscoped_package_name_is_first_segment(
1375 pkg in "[a-z][a-z0-9-]{0,30}",
1376 subpath in "(/[a-z0-9-]{1,20}){0,3}",
1377 ) {
1378 let specifier = format!("{pkg}{subpath}");
1379 let extracted = extract_package_name(&specifier);
1380 prop_assert_eq!(extracted, pkg);
1381 }
1382
1383 #[test]
1385 fn bare_specifier_and_path_alias_no_panic(s in "[a-zA-Z0-9@#~/._-]{1,100}") {
1386 let _ = is_bare_specifier(&s);
1387 let _ = is_path_alias(&s);
1388 }
1389
1390 #[test]
1392 fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1393 let specifier = format!("@/{suffix}");
1394 prop_assert!(is_path_alias(&specifier));
1395 }
1396
1397 #[test]
1399 fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1400 let specifier = format!("~/{suffix}");
1401 prop_assert!(is_path_alias(&specifier));
1402 }
1403
1404 #[test]
1406 fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1407 let specifier = format!("#{suffix}");
1408 prop_assert!(is_path_alias(&specifier));
1409 }
1410
1411 #[test]
1413 fn node_modules_package_name_never_empty(
1414 pkg in "[a-z][a-z0-9-]{0,20}",
1415 file in "[a-z]{1,10}\\.(js|ts|mjs)",
1416 ) {
1417 let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
1418 if let Some(name) = extract_package_name_from_node_modules_path(&path) {
1419 prop_assert!(!name.is_empty());
1420 }
1421 }
1422 }
1423 }
1424}