1use std::path::{Path, PathBuf};
7
8use rustc_hash::FxHashMap;
9
10use fallow_types::discover::FileId;
11
12use super::types::{OUTPUT_DIRS, ResolveContext, ResolveResult, SOURCE_EXTS};
13
14pub(super) fn try_path_alias_fallback(
21 ctx: &ResolveContext<'_>,
22 specifier: &str,
23) -> Option<ResolveResult> {
24 for (prefix, replacement) in ctx.path_aliases {
25 if !specifier.starts_with(prefix.as_str()) {
26 continue;
27 }
28
29 let remainder = &specifier[prefix.len()..];
30 let substituted = if replacement.is_empty() {
33 format!("./{remainder}")
34 } else {
35 format!("./{replacement}/{remainder}")
36 };
37
38 if let Ok(resolved) = ctx.resolver.resolve(ctx.root, &substituted) {
43 let resolved_path = resolved.path();
44 if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
46 return Some(ResolveResult::InternalModule(file_id));
47 }
48 if let Ok(canonical) = dunce::canonicalize(resolved_path) {
50 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
51 return Some(ResolveResult::InternalModule(file_id));
52 }
53 if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
54 return Some(ResolveResult::InternalModule(file_id));
55 }
56 if let Some(file_id) =
57 try_pnpm_workspace_fallback(&canonical, ctx.path_to_id, ctx.workspace_roots)
58 {
59 return Some(ResolveResult::InternalModule(file_id));
60 }
61 if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
62 return Some(ResolveResult::NpmPackage(pkg_name));
63 }
64 return Some(ResolveResult::ExternalFile(canonical));
65 }
66 }
67 }
68 None
69}
70
71pub(super) fn try_scss_partial_fallback(
80 ctx: &ResolveContext<'_>,
81 from_file: &Path,
82 specifier: &str,
83) -> Option<ResolveResult> {
84 if specifier.contains(':') {
86 return None;
87 }
88
89 let spec_path = Path::new(specifier);
90 let filename = spec_path.file_name()?.to_str()?;
91
92 if filename.starts_with('_') {
94 return None;
95 }
96
97 let partial_filename = format!("_{filename}");
99 let partial_specifier = if let Some(parent) = spec_path.parent()
100 && !parent.as_os_str().is_empty()
101 {
102 format!("{}/{partial_filename}", parent.display())
103 } else {
104 partial_filename
105 };
106
107 if let Some(result) = try_resolve_scss(ctx, from_file, &partial_specifier) {
108 return Some(result);
109 }
110
111 let index_partial = format!("{specifier}/_index");
113 if let Some(result) = try_resolve_scss(ctx, from_file, &index_partial) {
114 return Some(result);
115 }
116
117 let index_plain = format!("{specifier}/index");
118 try_resolve_scss(ctx, from_file, &index_plain)
119}
120
121fn try_resolve_scss(
123 ctx: &ResolveContext<'_>,
124 from_file: &Path,
125 specifier: &str,
126) -> Option<ResolveResult> {
127 let resolved = ctx.resolver.resolve_file(from_file, specifier).ok()?;
128 let resolved_path = resolved.path();
129
130 if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
131 return Some(ResolveResult::InternalModule(file_id));
132 }
133 if let Ok(canonical) = dunce::canonicalize(resolved_path)
134 && let Some(&file_id) = ctx.path_to_id.get(canonical.as_path())
135 {
136 return Some(ResolveResult::InternalModule(file_id));
137 }
138 None
139}
140
141pub(super) fn try_scss_include_path_fallback(
159 ctx: &ResolveContext<'_>,
160 from_file: &Path,
161 specifier: &str,
162) -> Option<ResolveResult> {
163 if ctx.scss_include_paths.is_empty() {
164 return None;
165 }
166 if !from_file
167 .extension()
168 .is_some_and(|e| e == "scss" || e == "sass")
169 {
170 return None;
171 }
172 if specifier.contains(':') {
174 return None;
175 }
176 let bare = specifier.strip_prefix("./")?;
180 if bare.starts_with("..") || bare.starts_with('/') {
181 return None;
182 }
183
184 for include_dir in ctx.scss_include_paths {
185 if let Some(file_id) = find_scss_in_dir(include_dir, bare, ctx) {
186 return Some(ResolveResult::InternalModule(file_id));
187 }
188 }
189 None
190}
191
192fn find_scss_in_dir(include_dir: &Path, bare: &str, ctx: &ResolveContext<'_>) -> Option<FileId> {
196 let bare_path = Path::new(bare);
197 let has_scss_ext = matches!(
198 bare_path.extension().and_then(|e| e.to_str()),
199 Some(ext) if ext.eq_ignore_ascii_case("scss") || ext.eq_ignore_ascii_case("sass")
200 );
201
202 let parent = bare_path.parent();
205 let stem_with_ext = bare_path.file_name()?.to_str()?;
206 let stem_without_ext = bare_path.file_stem().and_then(|s| s.to_str())?;
207
208 let build = |rel: &Path| -> std::path::PathBuf { include_dir.join(rel) };
209 let join_with_parent = |name: &str| -> std::path::PathBuf {
210 parent.map_or_else(|| build(Path::new(name)), |p| build(&p.join(name)))
211 };
212
213 let exts: &[&str] = if has_scss_ext {
214 &[""]
215 } else {
216 &["scss", "sass"]
217 };
218
219 for ext in exts {
220 let suffix = if ext.is_empty() {
221 String::new()
222 } else {
223 format!(".{ext}")
224 };
225 let direct = if ext.is_empty() {
227 build(bare_path)
228 } else {
229 join_with_parent(&format!("{stem_with_ext}{suffix}"))
230 };
231 if let Some(fid) = lookup_scss_path(&direct, ctx) {
232 return Some(fid);
233 }
234 let partial_name = if ext.is_empty() {
236 format!("_{stem_with_ext}")
237 } else {
238 format!("_{stem_without_ext}{suffix}")
239 };
240 let partial = join_with_parent(&partial_name);
241 if let Some(fid) = lookup_scss_path(&partial, ctx) {
242 return Some(fid);
243 }
244 if ext.is_empty() {
245 continue;
247 }
248 let idx_partial = build(bare_path).join(format!("_index{suffix}"));
250 if let Some(fid) = lookup_scss_path(&idx_partial, ctx) {
251 return Some(fid);
252 }
253 let idx_plain = build(bare_path).join(format!("index{suffix}"));
254 if let Some(fid) = lookup_scss_path(&idx_plain, ctx) {
255 return Some(fid);
256 }
257 }
258 None
259}
260
261fn lookup_scss_path(candidate: &Path, ctx: &ResolveContext<'_>) -> Option<FileId> {
264 if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
265 return Some(file_id);
266 }
267 if let Ok(canonical) = dunce::canonicalize(candidate) {
268 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
269 return Some(file_id);
270 }
271 if let Some(fallback) = ctx.canonical_fallback
272 && let Some(file_id) = fallback.get(&canonical)
273 {
274 return Some(file_id);
275 }
276 }
277 None
278}
279
280pub(super) fn try_scss_node_modules_fallback(
302 _ctx: &ResolveContext<'_>,
303 from_file: &Path,
304 specifier: &str,
305) -> Option<ResolveResult> {
306 if specifier.contains(':') {
308 return None;
309 }
310 if !from_file
311 .extension()
312 .is_some_and(|e| e == "scss" || e == "sass")
313 {
314 return None;
315 }
316 let bare = specifier.strip_prefix("./")?;
320 if bare.starts_with("..") || bare.starts_with('/') {
321 return None;
322 }
323 if bare.is_empty() {
327 return None;
328 }
329
330 let mut dir = from_file.parent()?;
338 loop {
339 let nm_dir = dir.join("node_modules");
340 if nm_dir.is_dir()
341 && let Some(path) = find_scss_in_node_modules(&nm_dir, bare)
342 && let Some(pkg_name) = extract_package_name_from_node_modules_path(&path)
343 {
344 return Some(ResolveResult::NpmPackage(pkg_name));
345 }
346 let Some(parent) = dir.parent() else {
347 break;
348 };
349 dir = parent;
350 }
351 None
352}
353
354fn find_scss_in_node_modules(nm_dir: &Path, bare: &str) -> Option<PathBuf> {
363 let bare_path = Path::new(bare);
364 let file_name = bare_path.file_name()?.to_str()?;
365 let parent = bare_path.parent();
366 let join_with_parent = |name: &str| -> PathBuf {
367 parent.map_or_else(|| nm_dir.join(name), |p| nm_dir.join(p).join(name))
368 };
369
370 for ext in &["scss", "sass", "css"] {
374 let candidate = join_with_parent(&format!("{file_name}.{ext}"));
375 if candidate.is_file() {
376 return Some(candidate);
377 }
378 }
379 for ext in &["scss", "sass"] {
382 let candidate = join_with_parent(&format!("_{file_name}.{ext}"));
383 if candidate.is_file() {
384 return Some(candidate);
385 }
386 }
387 for ext in &["scss", "sass"] {
389 let idx_partial = nm_dir.join(bare).join(format!("_index.{ext}"));
390 if idx_partial.is_file() {
391 return Some(idx_partial);
392 }
393 let idx_plain = nm_dir.join(bare).join(format!("index.{ext}"));
394 if idx_plain.is_file() {
395 return Some(idx_plain);
396 }
397 }
398 let exact = nm_dir.join(bare);
401 if exact.is_file() {
402 return Some(exact);
403 }
404 None
405}
406
407pub(super) fn try_source_fallback(
419 resolved: &Path,
420 path_to_id: &FxHashMap<&Path, FileId>,
421) -> Option<FileId> {
422 let components: Vec<_> = resolved.components().collect();
423
424 let is_output_dir = |c: &std::path::Component| -> bool {
425 if let std::path::Component::Normal(s) = c
426 && let Some(name) = s.to_str()
427 {
428 return OUTPUT_DIRS.contains(&name);
429 }
430 false
431 };
432
433 let last_output_pos = components.iter().rposition(&is_output_dir)?;
437
438 let mut first_output_pos = last_output_pos;
441 while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
442 first_output_pos -= 1;
443 }
444
445 let prefix: PathBuf = components[..first_output_pos].iter().collect();
447
448 let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
450 suffix.file_stem()?; for ext in SOURCE_EXTS {
454 let source_candidate = prefix.join("src").join(suffix.with_extension(ext));
455 if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
456 return Some(file_id);
457 }
458 }
459
460 None
461}
462
463pub fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
469 let components: Vec<&str> = path
470 .components()
471 .filter_map(|c| match c {
472 std::path::Component::Normal(s) => s.to_str(),
473 _ => None,
474 })
475 .collect();
476
477 let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
479
480 let after = &components[nm_idx + 1..];
481 if after.is_empty() {
482 return None;
483 }
484
485 if after[0].starts_with('@') {
486 if after.len() >= 2 {
488 Some(format!("{}/{}", after[0], after[1]))
489 } else {
490 Some(after[0].to_string())
491 }
492 } else {
493 Some(after[0].to_string())
494 }
495}
496
497pub(super) fn try_pnpm_workspace_fallback(
506 path: &Path,
507 path_to_id: &FxHashMap<&Path, FileId>,
508 workspace_roots: &FxHashMap<&str, &Path>,
509) -> Option<FileId> {
510 let components: Vec<&str> = path
512 .components()
513 .filter_map(|c| match c {
514 std::path::Component::Normal(s) => s.to_str(),
515 _ => None,
516 })
517 .collect();
518
519 let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
521
522 let after_pnpm = &components[pnpm_idx + 1..];
525
526 let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
528 let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
529
530 if after_inner_nm.is_empty() {
531 return None;
532 }
533
534 let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
536 if after_inner_nm.len() >= 2 {
537 (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
538 } else {
539 return None;
540 }
541 } else {
542 (after_inner_nm[0].to_string(), 1)
543 };
544
545 let ws_root = workspace_roots.get(pkg_name.as_str())?;
547
548 let relative_parts = &after_inner_nm[pkg_name_components..];
550 if relative_parts.is_empty() {
551 return None;
552 }
553
554 let relative_path: PathBuf = relative_parts.iter().collect();
555
556 let direct = ws_root.join(&relative_path);
558 if let Some(&file_id) = path_to_id.get(direct.as_path()) {
559 return Some(file_id);
560 }
561
562 try_source_fallback(&direct, path_to_id)
564}
565
566pub(super) fn try_workspace_package_fallback(
590 ctx: &ResolveContext<'_>,
591 specifier: &str,
592) -> Option<ResolveResult> {
593 if !super::path_info::is_bare_specifier(specifier) {
595 return None;
596 }
597 let pkg_name = super::path_info::extract_package_name(specifier);
598 let ws_root = *ctx.workspace_roots.get(pkg_name.as_str())?;
599
600 let subpath = specifier
603 .strip_prefix(pkg_name.as_str())
604 .and_then(|s| s.strip_prefix('/'))
605 .unwrap_or("");
606
607 let root_file = ws_root.join("__fallow_ws_self_resolve__");
610 let rel_spec = if subpath.is_empty() {
611 "./".to_string()
612 } else {
613 format!("./{subpath}")
614 };
615
616 let resolved = ctx.resolver.resolve_file(&root_file, &rel_spec).ok()?;
617 let resolved_path = resolved.path();
618
619 if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
620 return Some(ResolveResult::InternalModule(file_id));
621 }
622 if let Ok(canonical) = dunce::canonicalize(resolved_path) {
623 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
624 return Some(ResolveResult::InternalModule(file_id));
625 }
626 if let Some(fallback) = ctx.canonical_fallback
627 && let Some(file_id) = fallback.get(&canonical)
628 {
629 return Some(ResolveResult::InternalModule(file_id));
630 }
631 if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
632 return Some(ResolveResult::InternalModule(file_id));
633 }
634 }
635 None
636}
637
638pub(super) fn make_glob_from_pattern(
640 pattern: &fallow_types::extract::DynamicImportPattern,
641) -> String {
642 if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
644 return pattern.prefix.clone();
645 }
646 pattern.suffix.as_ref().map_or_else(
647 || format!("{}*", pattern.prefix),
648 |suffix| format!("{}*{}", pattern.prefix, suffix),
649 )
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655
656 #[test]
657 fn test_extract_package_name_from_node_modules_path_regular() {
658 let path = PathBuf::from("/project/node_modules/react/index.js");
659 assert_eq!(
660 extract_package_name_from_node_modules_path(&path),
661 Some("react".to_string())
662 );
663 }
664
665 #[test]
666 fn test_extract_package_name_from_node_modules_path_scoped() {
667 let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
668 assert_eq!(
669 extract_package_name_from_node_modules_path(&path),
670 Some("@babel/core".to_string())
671 );
672 }
673
674 #[test]
675 fn test_extract_package_name_from_node_modules_path_nested() {
676 let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
678 assert_eq!(
679 extract_package_name_from_node_modules_path(&path),
680 Some("pkg-b".to_string())
681 );
682 }
683
684 #[test]
685 fn test_extract_package_name_from_node_modules_path_deep_subpath() {
686 let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
687 assert_eq!(
688 extract_package_name_from_node_modules_path(&path),
689 Some("react-dom".to_string())
690 );
691 }
692
693 #[test]
694 fn test_extract_package_name_from_node_modules_path_no_node_modules() {
695 let path = PathBuf::from("/project/src/components/Button.tsx");
696 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
697 }
698
699 #[test]
700 fn test_extract_package_name_from_node_modules_path_just_node_modules() {
701 let path = PathBuf::from("/project/node_modules");
702 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
703 }
704
705 #[test]
706 fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
707 let path = PathBuf::from("/project/node_modules/@scope");
709 assert_eq!(
710 extract_package_name_from_node_modules_path(&path),
711 Some("@scope".to_string())
712 );
713 }
714
715 #[test]
716 fn test_resolve_specifier_node_modules_returns_npm_package() {
717 let path =
723 PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
724 assert_eq!(
725 extract_package_name_from_node_modules_path(&path),
726 Some("styled-components".to_string())
727 );
728
729 let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
730 assert_eq!(
731 extract_package_name_from_node_modules_path(&path),
732 Some("next".to_string())
733 );
734 }
735
736 #[test]
737 fn test_try_source_fallback_dist_to_src() {
738 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
739 let mut path_to_id = FxHashMap::default();
740 path_to_id.insert(src_path.as_path(), FileId(0));
741
742 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
743 assert_eq!(
744 try_source_fallback(&dist_path, &path_to_id),
745 Some(FileId(0)),
746 "dist/utils.js should fall back to src/utils.ts"
747 );
748 }
749
750 #[test]
751 fn test_try_source_fallback_build_to_src() {
752 let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
753 let mut path_to_id = FxHashMap::default();
754 path_to_id.insert(src_path.as_path(), FileId(1));
755
756 let build_path = PathBuf::from("/project/packages/core/build/index.js");
757 assert_eq!(
758 try_source_fallback(&build_path, &path_to_id),
759 Some(FileId(1)),
760 "build/index.js should fall back to src/index.tsx"
761 );
762 }
763
764 #[test]
765 fn test_try_source_fallback_no_match() {
766 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
767
768 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
769 assert_eq!(
770 try_source_fallback(&dist_path, &path_to_id),
771 None,
772 "should return None when no source file exists"
773 );
774 }
775
776 #[test]
777 fn test_try_source_fallback_non_output_dir() {
778 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
779 let mut path_to_id = FxHashMap::default();
780 path_to_id.insert(src_path.as_path(), FileId(0));
781
782 let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
784 assert_eq!(
785 try_source_fallback(&normal_path, &path_to_id),
786 None,
787 "non-output directory path should not trigger fallback"
788 );
789 }
790
791 #[test]
792 fn test_try_source_fallback_nested_path() {
793 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
794 let mut path_to_id = FxHashMap::default();
795 path_to_id.insert(src_path.as_path(), FileId(2));
796
797 let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
798 assert_eq!(
799 try_source_fallback(&dist_path, &path_to_id),
800 Some(FileId(2)),
801 "nested dist path should fall back to nested src path"
802 );
803 }
804
805 #[test]
806 fn test_try_source_fallback_nested_dist_esm() {
807 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
808 let mut path_to_id = FxHashMap::default();
809 path_to_id.insert(src_path.as_path(), FileId(0));
810
811 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
812 assert_eq!(
813 try_source_fallback(&dist_path, &path_to_id),
814 Some(FileId(0)),
815 "dist/esm/utils.mjs should fall back to src/utils.ts"
816 );
817 }
818
819 #[test]
820 fn test_try_source_fallback_nested_build_cjs() {
821 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
822 let mut path_to_id = FxHashMap::default();
823 path_to_id.insert(src_path.as_path(), FileId(1));
824
825 let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
826 assert_eq!(
827 try_source_fallback(&build_path, &path_to_id),
828 Some(FileId(1)),
829 "build/cjs/index.cjs should fall back to src/index.ts"
830 );
831 }
832
833 #[test]
834 fn test_try_source_fallback_nested_dist_esm_deep_path() {
835 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
836 let mut path_to_id = FxHashMap::default();
837 path_to_id.insert(src_path.as_path(), FileId(2));
838
839 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
840 assert_eq!(
841 try_source_fallback(&dist_path, &path_to_id),
842 Some(FileId(2)),
843 "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
844 );
845 }
846
847 #[test]
848 fn test_try_source_fallback_triple_nested_output_dirs() {
849 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
850 let mut path_to_id = FxHashMap::default();
851 path_to_id.insert(src_path.as_path(), FileId(0));
852
853 let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
854 assert_eq!(
855 try_source_fallback(&dist_path, &path_to_id),
856 Some(FileId(0)),
857 "out/dist/esm/utils.mjs should fall back to src/utils.ts"
858 );
859 }
860
861 #[test]
862 fn test_try_source_fallback_parent_dir_named_build() {
863 let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
864 let mut path_to_id = FxHashMap::default();
865 path_to_id.insert(src_path.as_path(), FileId(0));
866
867 let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
868 assert_eq!(
869 try_source_fallback(&dist_path, &path_to_id),
870 Some(FileId(0)),
871 "should resolve dist/ within project, not match parent 'build' dir"
872 );
873 }
874
875 #[test]
876 fn test_pnpm_store_path_extract_package_name() {
877 let path =
879 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
880 assert_eq!(
881 extract_package_name_from_node_modules_path(&path),
882 Some("react".to_string())
883 );
884 }
885
886 #[test]
887 fn test_pnpm_store_path_scoped_package() {
888 let path = PathBuf::from(
889 "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
890 );
891 assert_eq!(
892 extract_package_name_from_node_modules_path(&path),
893 Some("@babel/core".to_string())
894 );
895 }
896
897 #[test]
898 fn test_pnpm_store_path_with_peer_deps() {
899 let path = PathBuf::from(
900 "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
901 );
902 assert_eq!(
903 extract_package_name_from_node_modules_path(&path),
904 Some("webpack".to_string())
905 );
906 }
907
908 #[test]
909 fn test_try_pnpm_workspace_fallback_dist_to_src() {
910 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
911 let mut path_to_id = FxHashMap::default();
912 path_to_id.insert(src_path.as_path(), FileId(0));
913
914 let mut workspace_roots = FxHashMap::default();
915 let ws_root = PathBuf::from("/project/packages/ui");
916 workspace_roots.insert("@myorg/ui", ws_root.as_path());
917
918 let pnpm_path = PathBuf::from(
920 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
921 );
922 assert_eq!(
923 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
924 Some(FileId(0)),
925 ".pnpm workspace path should fall back to src/utils.ts"
926 );
927 }
928
929 #[test]
930 fn test_try_pnpm_workspace_fallback_direct_source() {
931 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
932 let mut path_to_id = FxHashMap::default();
933 path_to_id.insert(src_path.as_path(), FileId(1));
934
935 let mut workspace_roots = FxHashMap::default();
936 let ws_root = PathBuf::from("/project/packages/core");
937 workspace_roots.insert("@myorg/core", ws_root.as_path());
938
939 let pnpm_path = PathBuf::from(
941 "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
942 );
943 assert_eq!(
944 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
945 Some(FileId(1)),
946 ".pnpm workspace path with src/ should resolve directly"
947 );
948 }
949
950 #[test]
951 fn test_try_pnpm_workspace_fallback_non_workspace_package() {
952 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
953
954 let mut workspace_roots = FxHashMap::default();
955 let ws_root = PathBuf::from("/project/packages/ui");
956 workspace_roots.insert("@myorg/ui", ws_root.as_path());
957
958 let pnpm_path =
960 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
961 assert_eq!(
962 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
963 None,
964 "non-workspace package in .pnpm should return None"
965 );
966 }
967
968 #[test]
969 fn test_try_pnpm_workspace_fallback_unscoped_package() {
970 let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
971 let mut path_to_id = FxHashMap::default();
972 path_to_id.insert(src_path.as_path(), FileId(2));
973
974 let mut workspace_roots = FxHashMap::default();
975 let ws_root = PathBuf::from("/project/packages/utils");
976 workspace_roots.insert("my-utils", ws_root.as_path());
977
978 let pnpm_path = PathBuf::from(
980 "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
981 );
982 assert_eq!(
983 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
984 Some(FileId(2)),
985 "unscoped workspace package in .pnpm should resolve"
986 );
987 }
988
989 #[test]
990 fn test_try_pnpm_workspace_fallback_nested_path() {
991 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
992 let mut path_to_id = FxHashMap::default();
993 path_to_id.insert(src_path.as_path(), FileId(3));
994
995 let mut workspace_roots = FxHashMap::default();
996 let ws_root = PathBuf::from("/project/packages/ui");
997 workspace_roots.insert("@myorg/ui", ws_root.as_path());
998
999 let pnpm_path = PathBuf::from(
1001 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1002 );
1003 assert_eq!(
1004 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1005 Some(FileId(3)),
1006 "nested .pnpm workspace path should resolve through source fallback"
1007 );
1008 }
1009
1010 #[test]
1011 fn test_try_pnpm_workspace_fallback_no_pnpm() {
1012 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1013 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1014
1015 let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1017 assert_eq!(
1018 try_pnpm_workspace_fallback(®ular_path, &path_to_id, &workspace_roots),
1019 None,
1020 );
1021 }
1022
1023 #[test]
1024 fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1025 let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1026 let mut path_to_id = FxHashMap::default();
1027 path_to_id.insert(src_path.as_path(), FileId(4));
1028
1029 let mut workspace_roots = FxHashMap::default();
1030 let ws_root = PathBuf::from("/project/packages/ui");
1031 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1032
1033 let pnpm_path = PathBuf::from(
1035 "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1036 );
1037 assert_eq!(
1038 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1039 Some(FileId(4)),
1040 ".pnpm path with peer dep suffix should still resolve"
1041 );
1042 }
1043
1044 #[test]
1047 fn make_glob_prefix_only_no_suffix() {
1048 let pattern = fallow_types::extract::DynamicImportPattern {
1049 prefix: "./locales/".to_string(),
1050 suffix: None,
1051 span: oxc_span::Span::default(),
1052 };
1053 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*");
1054 }
1055
1056 #[test]
1057 fn make_glob_prefix_with_suffix() {
1058 let pattern = fallow_types::extract::DynamicImportPattern {
1059 prefix: "./locales/".to_string(),
1060 suffix: Some(".json".to_string()),
1061 span: oxc_span::Span::default(),
1062 };
1063 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1064 }
1065
1066 #[test]
1067 fn make_glob_passthrough_star() {
1068 let pattern = fallow_types::extract::DynamicImportPattern {
1070 prefix: "./pages/**/*.tsx".to_string(),
1071 suffix: None,
1072 span: oxc_span::Span::default(),
1073 };
1074 assert_eq!(make_glob_from_pattern(&pattern), "./pages/**/*.tsx");
1075 }
1076
1077 #[test]
1078 fn make_glob_passthrough_brace() {
1079 let pattern = fallow_types::extract::DynamicImportPattern {
1080 prefix: "./i18n/{en,de,fr}.json".to_string(),
1081 suffix: None,
1082 span: oxc_span::Span::default(),
1083 };
1084 assert_eq!(make_glob_from_pattern(&pattern), "./i18n/{en,de,fr}.json");
1085 }
1086
1087 #[test]
1088 fn make_glob_empty_prefix_no_suffix() {
1089 let pattern = fallow_types::extract::DynamicImportPattern {
1090 prefix: String::new(),
1091 suffix: None,
1092 span: oxc_span::Span::default(),
1093 };
1094 assert_eq!(make_glob_from_pattern(&pattern), "*");
1095 }
1096
1097 #[test]
1098 fn make_glob_empty_prefix_with_suffix() {
1099 let pattern = fallow_types::extract::DynamicImportPattern {
1100 prefix: String::new(),
1101 suffix: Some(".ts".to_string()),
1102 span: oxc_span::Span::default(),
1103 };
1104 assert_eq!(make_glob_from_pattern(&pattern), "*.ts");
1105 }
1106
1107 #[test]
1110 fn make_glob_template_literal_prefix_only() {
1111 let pattern = fallow_types::extract::DynamicImportPattern {
1113 prefix: "./pages/".to_string(),
1114 suffix: None,
1115 span: oxc_span::Span::default(),
1116 };
1117 assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1118 }
1119
1120 #[test]
1121 fn make_glob_template_literal_with_extension_suffix() {
1122 let pattern = fallow_types::extract::DynamicImportPattern {
1124 prefix: "./locales/".to_string(),
1125 suffix: Some(".json".to_string()),
1126 span: oxc_span::Span::default(),
1127 };
1128 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1129 }
1130
1131 #[test]
1132 fn make_glob_template_literal_deep_prefix() {
1133 let pattern = fallow_types::extract::DynamicImportPattern {
1136 prefix: "./modules/".to_string(),
1137 suffix: None,
1138 span: oxc_span::Span::default(),
1139 };
1140 assert_eq!(make_glob_from_pattern(&pattern), "./modules/*");
1141 }
1142
1143 #[test]
1144 fn make_glob_string_concat_prefix() {
1145 let pattern = fallow_types::extract::DynamicImportPattern {
1147 prefix: "./pages/".to_string(),
1148 suffix: None,
1149 span: oxc_span::Span::default(),
1150 };
1151 assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1152 }
1153
1154 #[test]
1155 fn make_glob_string_concat_with_extension() {
1156 let pattern = fallow_types::extract::DynamicImportPattern {
1158 prefix: "./views/".to_string(),
1159 suffix: Some(".vue".to_string()),
1160 span: oxc_span::Span::default(),
1161 };
1162 assert_eq!(make_glob_from_pattern(&pattern), "./views/*.vue");
1163 }
1164
1165 #[test]
1168 fn make_glob_import_meta_glob_recursive() {
1169 let pattern = fallow_types::extract::DynamicImportPattern {
1171 prefix: "./components/**/*.vue".to_string(),
1172 suffix: None,
1173 span: oxc_span::Span::default(),
1174 };
1175 assert_eq!(
1176 make_glob_from_pattern(&pattern),
1177 "./components/**/*.vue",
1178 "import.meta.glob patterns with * should pass through as-is"
1179 );
1180 }
1181
1182 #[test]
1183 fn make_glob_import_meta_glob_brace_expansion() {
1184 let pattern = fallow_types::extract::DynamicImportPattern {
1186 prefix: "./plugins/{auth,analytics}.ts".to_string(),
1187 suffix: None,
1188 span: oxc_span::Span::default(),
1189 };
1190 assert_eq!(
1191 make_glob_from_pattern(&pattern),
1192 "./plugins/{auth,analytics}.ts",
1193 "import.meta.glob patterns with braces should pass through as-is"
1194 );
1195 }
1196
1197 #[test]
1198 fn make_glob_import_meta_glob_star_with_brace() {
1199 let pattern = fallow_types::extract::DynamicImportPattern {
1201 prefix: "./routes/**/*.{ts,tsx}".to_string(),
1202 suffix: None,
1203 span: oxc_span::Span::default(),
1204 };
1205 assert_eq!(
1206 make_glob_from_pattern(&pattern),
1207 "./routes/**/*.{ts,tsx}",
1208 "combined * and brace patterns should pass through"
1209 );
1210 }
1211
1212 #[test]
1213 fn make_glob_import_meta_glob_ignores_suffix_when_star_present() {
1214 let pattern = fallow_types::extract::DynamicImportPattern {
1216 prefix: "./*.ts".to_string(),
1217 suffix: Some(".extra".to_string()),
1218 span: oxc_span::Span::default(),
1219 };
1220 assert_eq!(
1221 make_glob_from_pattern(&pattern),
1222 "./*.ts",
1223 "when prefix has glob chars, suffix is ignored (prefix used as-is)"
1224 );
1225 }
1226
1227 #[test]
1230 fn make_glob_single_dot_prefix() {
1231 let pattern = fallow_types::extract::DynamicImportPattern {
1232 prefix: "./".to_string(),
1233 suffix: None,
1234 span: oxc_span::Span::default(),
1235 };
1236 assert_eq!(make_glob_from_pattern(&pattern), "./*");
1237 }
1238
1239 #[test]
1240 fn make_glob_prefix_without_trailing_slash() {
1241 let pattern = fallow_types::extract::DynamicImportPattern {
1243 prefix: "./config".to_string(),
1244 suffix: None,
1245 span: oxc_span::Span::default(),
1246 };
1247 assert_eq!(make_glob_from_pattern(&pattern), "./config*");
1248 }
1249
1250 #[test]
1251 fn make_glob_prefix_with_dotdot() {
1252 let pattern = fallow_types::extract::DynamicImportPattern {
1253 prefix: "../shared/".to_string(),
1254 suffix: Some(".ts".to_string()),
1255 span: oxc_span::Span::default(),
1256 };
1257 assert_eq!(make_glob_from_pattern(&pattern), "../shared/*.ts");
1258 }
1259
1260 #[test]
1263 fn test_extract_package_name_with_pnpm_plus_encoded_scope() {
1264 let path = PathBuf::from(
1267 "/project/node_modules/.pnpm/@mui+material@5.15.0/node_modules/@mui/material/index.js",
1268 );
1269 assert_eq!(
1270 extract_package_name_from_node_modules_path(&path),
1271 Some("@mui/material".to_string())
1272 );
1273 }
1274
1275 #[test]
1276 fn test_extract_package_name_windows_style_path() {
1277 let path = PathBuf::from("/project/node_modules/typescript/lib/tsc.js");
1279 assert_eq!(
1280 extract_package_name_from_node_modules_path(&path),
1281 Some("typescript".to_string())
1282 );
1283 }
1284
1285 #[test]
1288 fn test_try_source_fallback_out_dir() {
1289 let src_path = PathBuf::from("/project/packages/api/src/handler.ts");
1290 let mut path_to_id = FxHashMap::default();
1291 path_to_id.insert(src_path.as_path(), FileId(5));
1292
1293 let out_path = PathBuf::from("/project/packages/api/out/handler.js");
1294 assert_eq!(
1295 try_source_fallback(&out_path, &path_to_id),
1296 Some(FileId(5)),
1297 "out/handler.js should fall back to src/handler.ts"
1298 );
1299 }
1300
1301 #[test]
1302 fn test_try_source_fallback_mts_extension() {
1303 let src_path = PathBuf::from("/project/packages/lib/src/utils.mts");
1304 let mut path_to_id = FxHashMap::default();
1305 path_to_id.insert(src_path.as_path(), FileId(6));
1306
1307 let dist_path = PathBuf::from("/project/packages/lib/dist/utils.mjs");
1308 assert_eq!(
1309 try_source_fallback(&dist_path, &path_to_id),
1310 Some(FileId(6)),
1311 "dist/utils.mjs should fall back to src/utils.mts"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_try_source_fallback_cts_extension() {
1317 let src_path = PathBuf::from("/project/packages/lib/src/config.cts");
1318 let mut path_to_id = FxHashMap::default();
1319 path_to_id.insert(src_path.as_path(), FileId(7));
1320
1321 let dist_path = PathBuf::from("/project/packages/lib/dist/config.cjs");
1322 assert_eq!(
1323 try_source_fallback(&dist_path, &path_to_id),
1324 Some(FileId(7)),
1325 "dist/config.cjs should fall back to src/config.cts"
1326 );
1327 }
1328
1329 #[test]
1330 fn test_try_source_fallback_jsx_extension() {
1331 let src_path = PathBuf::from("/project/packages/ui/src/App.jsx");
1332 let mut path_to_id = FxHashMap::default();
1333 path_to_id.insert(src_path.as_path(), FileId(8));
1334
1335 let build_path = PathBuf::from("/project/packages/ui/build/App.js");
1336 assert_eq!(
1337 try_source_fallback(&build_path, &path_to_id),
1338 Some(FileId(8)),
1339 "build/App.js should fall back to src/App.jsx"
1340 );
1341 }
1342
1343 #[test]
1344 fn test_try_source_fallback_no_file_stem() {
1345 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1347 let dist_path = PathBuf::from("/project/packages/ui/dist/");
1348 assert_eq!(
1349 try_source_fallback(&dist_path, &path_to_id),
1350 None,
1351 "directory path with no file should return None"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_try_source_fallback_esm_subdir() {
1357 let src_path = PathBuf::from("/project/lib/src/index.ts");
1359 let mut path_to_id = FxHashMap::default();
1360 path_to_id.insert(src_path.as_path(), FileId(10));
1361
1362 let dist_path = PathBuf::from("/project/lib/esm/index.mjs");
1363 assert_eq!(
1364 try_source_fallback(&dist_path, &path_to_id),
1365 Some(FileId(10)),
1366 "standalone esm/ directory should fall back to src/"
1367 );
1368 }
1369
1370 #[test]
1371 fn test_try_source_fallback_cjs_subdir() {
1372 let src_path = PathBuf::from("/project/lib/src/index.ts");
1373 let mut path_to_id = FxHashMap::default();
1374 path_to_id.insert(src_path.as_path(), FileId(11));
1375
1376 let cjs_path = PathBuf::from("/project/lib/cjs/index.cjs");
1377 assert_eq!(
1378 try_source_fallback(&cjs_path, &path_to_id),
1379 Some(FileId(11)),
1380 "standalone cjs/ directory should fall back to src/"
1381 );
1382 }
1383
1384 #[test]
1387 fn test_try_pnpm_workspace_fallback_empty_after_pnpm() {
1388 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1390 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1391
1392 let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/node_modules");
1393 assert_eq!(
1394 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1395 None,
1396 "path ending at node_modules with nothing after should return None"
1397 );
1398 }
1399
1400 #[test]
1401 fn test_try_pnpm_workspace_fallback_scoped_package_only_scope() {
1402 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1404 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1405
1406 let pnpm_path =
1407 PathBuf::from("/project/node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope");
1408 assert_eq!(
1409 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1410 None,
1411 "scoped package without full name and no matching workspace should return None"
1412 );
1413 }
1414
1415 #[test]
1416 fn test_try_pnpm_workspace_fallback_no_inner_node_modules() {
1417 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1419 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1420
1421 let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/dist/index.js");
1422 assert_eq!(
1423 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1424 None,
1425 "path without inner node_modules after .pnpm should return None"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_try_pnpm_workspace_fallback_package_without_relative_path() {
1431 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1433 let mut workspace_roots = FxHashMap::default();
1434 let ws_root = PathBuf::from("/project/packages/ui");
1435 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1436
1437 let pnpm_path =
1438 PathBuf::from("/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui");
1439 assert_eq!(
1440 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1441 None,
1442 "path ending at package name with no relative file should return None"
1443 );
1444 }
1445
1446 #[test]
1447 fn test_try_pnpm_workspace_fallback_nested_dist_esm() {
1448 let src_path = PathBuf::from("/project/packages/ui/src/Button.ts");
1449 let mut path_to_id = FxHashMap::default();
1450 path_to_id.insert(src_path.as_path(), FileId(10));
1451
1452 let mut workspace_roots = FxHashMap::default();
1453 let ws_root = PathBuf::from("/project/packages/ui");
1454 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1455
1456 let pnpm_path = PathBuf::from(
1458 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/esm/Button.mjs",
1459 );
1460 assert_eq!(
1461 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1462 Some(FileId(10)),
1463 "pnpm path with nested dist/esm should resolve through source fallback"
1464 );
1465 }
1466}