1use std::path::{Path, PathBuf};
7
8use rustc_hash::FxHashMap;
9use serde_json::Value;
10
11use fallow_types::discover::FileId;
12
13use super::types::{OUTPUT_DIRS, PackageManifestInfo, ResolveContext, ResolveResult, SOURCE_EXTS};
14
15pub(super) fn try_path_alias_fallback(
22 ctx: &ResolveContext<'_>,
23 specifier: &str,
24) -> Option<ResolveResult> {
25 for (prefix, replacement) in ctx.path_aliases {
26 if !specifier.starts_with(prefix.as_str()) {
27 continue;
28 }
29
30 let remainder = &specifier[prefix.len()..];
31 let substituted = if replacement.is_empty() {
34 format!("./{remainder}")
35 } else {
36 format!("./{replacement}/{remainder}")
37 };
38
39 if let Ok(resolved) = ctx.resolver.resolve(ctx.root, &substituted) {
44 let resolved_path = resolved.path();
45 if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
47 return Some(ResolveResult::InternalModule(file_id));
48 }
49 if let Ok(canonical) = dunce::canonicalize(resolved_path) {
51 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
52 return Some(ResolveResult::InternalModule(file_id));
53 }
54 if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
55 return Some(ResolveResult::InternalModule(file_id));
56 }
57 if let Some(file_id) =
58 try_pnpm_workspace_fallback(&canonical, ctx.path_to_id, ctx.workspace_roots)
59 {
60 return Some(ResolveResult::InternalModule(file_id));
61 }
62 if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
63 return Some(ResolveResult::NpmPackage(pkg_name));
64 }
65 return Some(ResolveResult::ExternalFile(canonical));
66 }
67 }
68 }
69 None
70}
71
72pub(super) fn try_scss_partial_fallback(
81 ctx: &ResolveContext<'_>,
82 from_file: &Path,
83 specifier: &str,
84) -> Option<ResolveResult> {
85 if specifier.contains(':') {
87 return None;
88 }
89
90 let spec_path = Path::new(specifier);
91 let filename = spec_path.file_name()?.to_str()?;
92
93 if filename.starts_with('_') {
95 return None;
96 }
97
98 let partial_filename = format!("_{filename}");
100 let partial_specifier = if let Some(parent) = spec_path.parent()
101 && !parent.as_os_str().is_empty()
102 {
103 format!("{}/{partial_filename}", parent.display())
104 } else {
105 partial_filename
106 };
107
108 if let Some(result) = try_resolve_scss(ctx, from_file, &partial_specifier) {
109 return Some(result);
110 }
111
112 let index_partial = format!("{specifier}/_index");
114 if let Some(result) = try_resolve_scss(ctx, from_file, &index_partial) {
115 return Some(result);
116 }
117
118 let index_plain = format!("{specifier}/index");
119 try_resolve_scss(ctx, from_file, &index_plain)
120}
121
122pub(super) fn try_css_extension_fallback(
132 ctx: &ResolveContext<'_>,
133 from_file: &Path,
134 specifier: &str,
135) -> Option<ResolveResult> {
136 if specifier.contains(':') {
137 return None;
138 }
139 let spec_path = Path::new(specifier);
143 let already_css_ext = spec_path
144 .extension()
145 .and_then(|e| e.to_str())
146 .is_some_and(|e| {
147 e.eq_ignore_ascii_case("css")
148 || e.eq_ignore_ascii_case("scss")
149 || e.eq_ignore_ascii_case("sass")
150 });
151 if already_css_ext {
152 return try_resolve_scss(ctx, from_file, specifier);
153 }
154 for ext in ["scss", "sass", "css"] {
155 let candidate = format!("{specifier}.{ext}");
156 if let Some(result) = try_resolve_scss(ctx, from_file, &candidate) {
157 return Some(result);
158 }
159 }
160 None
161}
162
163fn try_resolve_scss(
165 ctx: &ResolveContext<'_>,
166 from_file: &Path,
167 specifier: &str,
168) -> Option<ResolveResult> {
169 let resolved = ctx.resolver.resolve_file(from_file, specifier).ok()?;
170 let resolved_path = resolved.path();
171
172 if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
173 return Some(ResolveResult::InternalModule(file_id));
174 }
175 if let Ok(canonical) = dunce::canonicalize(resolved_path)
176 && let Some(&file_id) = ctx.path_to_id.get(canonical.as_path())
177 {
178 return Some(ResolveResult::InternalModule(file_id));
179 }
180 None
181}
182
183pub(super) fn try_scss_include_path_fallback(
203 ctx: &ResolveContext<'_>,
204 from_file: &Path,
205 specifier: &str,
206 from_style: bool,
207) -> Option<ResolveResult> {
208 if ctx.scss_include_paths.is_empty() {
209 return None;
210 }
211 let is_scss_importer = from_file
212 .extension()
213 .is_some_and(|e| e == "scss" || e == "sass");
214 if !is_scss_importer && !from_style {
215 return None;
216 }
217 if specifier.contains(':') {
219 return None;
220 }
221 let bare = specifier.strip_prefix("./")?;
225 if bare.starts_with("..") || bare.starts_with('/') {
226 return None;
227 }
228
229 for include_dir in ctx.scss_include_paths {
230 if let Some(file_id) = find_scss_in_dir(include_dir, bare, ctx) {
231 return Some(ResolveResult::InternalModule(file_id));
232 }
233 }
234 None
235}
236
237fn find_scss_in_dir(include_dir: &Path, bare: &str, ctx: &ResolveContext<'_>) -> Option<FileId> {
241 let bare_path = Path::new(bare);
242 let has_scss_ext = matches!(
243 bare_path.extension().and_then(|e| e.to_str()),
244 Some(ext) if ext.eq_ignore_ascii_case("scss") || ext.eq_ignore_ascii_case("sass")
245 );
246
247 let parent = bare_path.parent();
250 let stem_with_ext = bare_path.file_name()?.to_str()?;
251 let stem_without_ext = bare_path.file_stem().and_then(|s| s.to_str())?;
252
253 let build = |rel: &Path| -> std::path::PathBuf { include_dir.join(rel) };
254 let join_with_parent = |name: &str| -> std::path::PathBuf {
255 parent.map_or_else(|| build(Path::new(name)), |p| build(&p.join(name)))
256 };
257
258 let exts: &[&str] = if has_scss_ext {
259 &[""]
260 } else {
261 &["scss", "sass"]
262 };
263
264 for ext in exts {
265 let suffix = if ext.is_empty() {
266 String::new()
267 } else {
268 format!(".{ext}")
269 };
270 let direct = if ext.is_empty() {
272 build(bare_path)
273 } else {
274 join_with_parent(&format!("{stem_with_ext}{suffix}"))
275 };
276 if let Some(fid) = lookup_scss_path(&direct, ctx) {
277 return Some(fid);
278 }
279 let partial_name = if ext.is_empty() {
281 format!("_{stem_with_ext}")
282 } else {
283 format!("_{stem_without_ext}{suffix}")
284 };
285 let partial = join_with_parent(&partial_name);
286 if let Some(fid) = lookup_scss_path(&partial, ctx) {
287 return Some(fid);
288 }
289 if ext.is_empty() {
290 continue;
292 }
293 let idx_partial = build(bare_path).join(format!("_index{suffix}"));
295 if let Some(fid) = lookup_scss_path(&idx_partial, ctx) {
296 return Some(fid);
297 }
298 let idx_plain = build(bare_path).join(format!("index{suffix}"));
299 if let Some(fid) = lookup_scss_path(&idx_plain, ctx) {
300 return Some(fid);
301 }
302 }
303 None
304}
305
306fn lookup_scss_path(candidate: &Path, ctx: &ResolveContext<'_>) -> Option<FileId> {
309 if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
310 return Some(file_id);
311 }
312 if let Ok(canonical) = dunce::canonicalize(candidate) {
313 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
314 return Some(file_id);
315 }
316 if let Some(fallback) = ctx.canonical_fallback
317 && let Some(file_id) = fallback.get(&canonical)
318 {
319 return Some(file_id);
320 }
321 }
322 None
323}
324
325pub(super) fn try_scss_node_modules_fallback(
347 _ctx: &ResolveContext<'_>,
348 from_file: &Path,
349 specifier: &str,
350 from_style: bool,
351) -> Option<ResolveResult> {
352 if specifier.contains(':') {
354 return None;
355 }
356 let is_scss_importer = from_file
357 .extension()
358 .is_some_and(|e| e == "scss" || e == "sass");
359 if !is_scss_importer && !from_style {
360 return None;
361 }
362 let bare = specifier.strip_prefix("./")?;
366 if bare.starts_with("..") || bare.starts_with('/') {
367 return None;
368 }
369 if bare.is_empty() {
373 return None;
374 }
375
376 let mut dir = from_file.parent()?;
384 loop {
385 let nm_dir = dir.join("node_modules");
386 if nm_dir.is_dir()
387 && let Some(path) = find_scss_in_node_modules(&nm_dir, bare)
388 && let Some(pkg_name) = extract_package_name_from_node_modules_path(&path)
389 {
390 return Some(ResolveResult::NpmPackage(pkg_name));
391 }
392 let Some(parent) = dir.parent() else {
393 break;
394 };
395 dir = parent;
396 }
397 None
398}
399
400fn find_scss_in_node_modules(nm_dir: &Path, bare: &str) -> Option<PathBuf> {
409 let bare_path = Path::new(bare);
410 let file_name = bare_path.file_name()?.to_str()?;
411 let parent = bare_path.parent();
412 let join_with_parent = |name: &str| -> PathBuf {
413 parent.map_or_else(|| nm_dir.join(name), |p| nm_dir.join(p).join(name))
414 };
415
416 for ext in &["scss", "sass", "css"] {
420 let candidate = join_with_parent(&format!("{file_name}.{ext}"));
421 if candidate.is_file() {
422 return Some(candidate);
423 }
424 }
425 for ext in &["scss", "sass"] {
428 let candidate = join_with_parent(&format!("_{file_name}.{ext}"));
429 if candidate.is_file() {
430 return Some(candidate);
431 }
432 }
433 for ext in &["scss", "sass"] {
435 let idx_partial = nm_dir.join(bare).join(format!("_index.{ext}"));
436 if idx_partial.is_file() {
437 return Some(idx_partial);
438 }
439 let idx_plain = nm_dir.join(bare).join(format!("index.{ext}"));
440 if idx_plain.is_file() {
441 return Some(idx_plain);
442 }
443 }
444 let exact = nm_dir.join(bare);
447 if exact.is_file() {
448 return Some(exact);
449 }
450 None
451}
452
453pub(super) fn try_source_fallback(
465 resolved: &Path,
466 path_to_id: &FxHashMap<&Path, FileId>,
467) -> Option<FileId> {
468 let components: Vec<_> = resolved.components().collect();
469
470 let is_output_dir = |c: &std::path::Component| -> bool {
471 if let std::path::Component::Normal(s) = c
472 && let Some(name) = s.to_str()
473 {
474 return OUTPUT_DIRS.contains(&name);
475 }
476 false
477 };
478
479 let last_output_pos = components.iter().rposition(&is_output_dir)?;
483
484 let mut first_output_pos = last_output_pos;
487 while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
488 first_output_pos -= 1;
489 }
490
491 let prefix: PathBuf = components[..first_output_pos].iter().collect();
493
494 let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
496 suffix.file_stem()?; for ext in SOURCE_EXTS {
500 let source_candidate = prefix.join("src").join(suffix.with_extension(ext));
501 if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
502 return Some(file_id);
503 }
504 }
505
506 None
507}
508
509pub(super) fn try_package_imports_fallback(
515 ctx: &ResolveContext<'_>,
516 from_file: &Path,
517 specifier: &str,
518) -> Option<ResolveResult> {
519 if !specifier.starts_with('#') {
520 return None;
521 }
522 let manifest = nearest_package_manifest(ctx.package_manifests, from_file)?;
523 let imports = manifest.package_json.imports.as_ref()?;
524 let PackageMapTarget::Target(target) =
525 package_map_target(imports, specifier, ctx.condition_names)
526 else {
527 return None;
528 };
529 let source_subpath = package_import_source_subpath(manifest, specifier);
530 resolve_package_map_target(ctx, manifest, &target, source_subpath.as_deref()).map(|file_id| {
531 match &manifest.name {
532 Some(package_name) => ResolveResult::InternalPackageModule {
533 file_id,
534 package_name: package_name.clone(),
535 },
536 None => ResolveResult::InternalModule(file_id),
537 }
538 })
539}
540
541#[derive(Debug, Clone, PartialEq, Eq)]
542enum PackageMapTarget {
543 NoMatch,
544 Blocked,
545 Target(String),
546}
547
548fn package_map_match_value(
549 value: &Value,
550 condition_names: &[String],
551 capture: Option<&str>,
552) -> PackageMapTarget {
553 resolve_package_map_value(value, condition_names, capture)
554 .map_or(PackageMapTarget::Blocked, PackageMapTarget::Target)
555}
556
557fn package_map_target(
558 map: &Value,
559 specifier_key: &str,
560 condition_names: &[String],
561) -> PackageMapTarget {
562 let Some(obj) = map.as_object() else {
563 if specifier_key == "." {
564 return package_map_match_value(map, condition_names, None);
565 }
566 return PackageMapTarget::NoMatch;
567 };
568
569 let has_subpath_keys = obj
570 .keys()
571 .any(|key| key == "." || key.starts_with("./") || key.starts_with('#'));
572 if !has_subpath_keys {
573 if specifier_key == "." {
574 return package_map_match_value(map, condition_names, None);
575 }
576 return PackageMapTarget::NoMatch;
577 }
578
579 if let Some(value) = obj.get(specifier_key) {
580 return package_map_match_value(value, condition_names, None);
581 }
582
583 let mut patterns: Vec<(&str, &Value, String)> = obj
584 .iter()
585 .filter_map(|(pattern, value)| {
586 package_map_pattern_capture(pattern, specifier_key)
587 .map(|capture| (pattern.as_str(), value, capture))
588 })
589 .collect();
590 patterns.sort_by(|(left, _, _), (right, _, _)| {
591 package_map_pattern_specificity(right).cmp(&package_map_pattern_specificity(left))
592 });
593
594 patterns
595 .first()
596 .map_or(PackageMapTarget::NoMatch, |(_, value, capture)| {
597 package_map_match_value(value, condition_names, Some(capture))
598 })
599}
600
601fn resolve_package_map_value(
602 value: &Value,
603 condition_names: &[String],
604 capture: Option<&str>,
605) -> Option<String> {
606 match value {
607 Value::String(target) => Some(match capture {
608 Some(capture) => target.replace('*', capture),
609 None => target.clone(),
610 }),
611 Value::Object(map) => {
612 for (condition, value) in map {
613 if (condition == "default"
614 || condition_names
615 .iter()
616 .any(|active_condition| active_condition == condition))
617 && let Some(target) = resolve_package_map_value(value, condition_names, capture)
618 {
619 return Some(target);
620 }
621 }
622 None
623 }
624 Value::Array(_) | Value::Bool(_) | Value::Null | Value::Number(_) => None,
625 }
626}
627
628fn package_map_pattern_capture(pattern: &str, specifier: &str) -> Option<String> {
629 let star = pattern.find('*')?;
630 if pattern[star + 1..].contains('*') {
631 return None;
632 }
633 let (prefix, suffix_with_star) = pattern.split_at(star);
634 let suffix = &suffix_with_star[1..];
635 let captured = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
636 Some(captured.to_string())
637}
638
639fn package_map_pattern_specificity(pattern: &str) -> (usize, usize) {
640 let star = pattern.find('*').unwrap_or(pattern.len());
641 (star, pattern.len())
642}
643
644fn package_import_source_subpath(
645 manifest: &PackageManifestInfo,
646 specifier: &str,
647) -> Option<PathBuf> {
648 let stripped = specifier.strip_prefix('#')?;
649 let without_package_name = manifest
650 .name
651 .as_deref()
652 .and_then(|name| stripped.strip_prefix(name))
653 .and_then(|rest| rest.strip_prefix('/'))
654 .unwrap_or(stripped);
655 if without_package_name.is_empty() {
656 None
657 } else {
658 Some(PathBuf::from(without_package_name))
659 }
660}
661
662fn nearest_package_manifest<'a>(
663 manifests: &'a [PackageManifestInfo],
664 from_file: &Path,
665) -> Option<&'a PackageManifestInfo> {
666 manifests
667 .iter()
668 .filter(|manifest| {
669 from_file.starts_with(&manifest.root) || from_file.starts_with(&manifest.canonical_root)
670 })
671 .max_by_key(|manifest| manifest.root.components().count())
672}
673
674fn find_package_manifest<'a>(
675 manifests: &'a [PackageManifestInfo],
676 package_name: &str,
677) -> Option<&'a PackageManifestInfo> {
678 manifests
679 .iter()
680 .find(|manifest| manifest.name.as_deref() == Some(package_name))
681}
682
683fn resolve_package_map_target(
684 ctx: &ResolveContext<'_>,
685 manifest: &PackageManifestInfo,
686 target: &str,
687 source_subpath: Option<&Path>,
688) -> Option<FileId> {
689 let target = target.strip_prefix("./")?;
690 if target.starts_with("../") || target.starts_with('/') {
691 return None;
692 }
693 let target_path = manifest.root.join(target);
694
695 lookup_internal_file_id(ctx, &target_path)
696 .or_else(|| try_source_fallback(&target_path, ctx.raw_path_to_id))
697 .or_else(|| try_source_fallback(&target_path, ctx.path_to_id))
698 .or_else(|| source_subpath.and_then(|subpath| try_source_subpath(ctx, manifest, subpath)))
699}
700
701fn try_source_subpath(
702 ctx: &ResolveContext<'_>,
703 manifest: &PackageManifestInfo,
704 subpath: &Path,
705) -> Option<FileId> {
706 if subpath.as_os_str().is_empty()
707 && let Some(source) = manifest.package_json.source.as_deref()
708 && let Some(source) = source.strip_prefix("./")
709 && let Some(file_id) = lookup_internal_file_id(ctx, &manifest.root.join(source))
710 {
711 return Some(file_id);
712 }
713
714 for ext in SOURCE_EXTS {
715 let direct = if subpath.as_os_str().is_empty() {
716 manifest.root.join("src").join(format!("index.{ext}"))
717 } else {
718 manifest.root.join("src").join(subpath).with_extension(ext)
719 };
720 if let Some(file_id) = lookup_internal_file_id(ctx, &direct) {
721 return Some(file_id);
722 }
723
724 if !subpath.as_os_str().is_empty() {
725 let index = manifest
726 .root
727 .join("src")
728 .join(subpath)
729 .join(format!("index.{ext}"));
730 if let Some(file_id) = lookup_internal_file_id(ctx, &index) {
731 return Some(file_id);
732 }
733 }
734
735 if subpath.as_os_str().is_empty() {
736 let root_index = manifest.root.join(format!("index.{ext}"));
737 if let Some(file_id) = lookup_internal_file_id(ctx, &root_index) {
738 return Some(file_id);
739 }
740 }
741 }
742
743 None
744}
745
746fn lookup_internal_file_id(ctx: &ResolveContext<'_>, candidate: &Path) -> Option<FileId> {
747 if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
748 return Some(file_id);
749 }
750 if let Some(&file_id) = ctx.path_to_id.get(candidate) {
751 return Some(file_id);
752 }
753 #[cfg(not(miri))]
754 if let Ok(canonical) = dunce::canonicalize(candidate) {
755 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
756 return Some(file_id);
757 }
758 if let Some(fallback) = ctx.canonical_fallback
759 && let Some(file_id) = fallback.get(&canonical)
760 {
761 return Some(file_id);
762 }
763 }
764 None
765}
766
767pub fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
773 let components: Vec<&str> = path
774 .components()
775 .filter_map(|c| match c {
776 std::path::Component::Normal(s) => s.to_str(),
777 _ => None,
778 })
779 .collect();
780
781 let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
783
784 let after = &components[nm_idx + 1..];
785 if after.is_empty() {
786 return None;
787 }
788
789 if after[0].starts_with('@') {
790 if after.len() >= 2 {
792 Some(format!("{}/{}", after[0], after[1]))
793 } else {
794 Some(after[0].to_string())
795 }
796 } else {
797 Some(after[0].to_string())
798 }
799}
800
801pub(super) fn try_pnpm_workspace_fallback(
810 path: &Path,
811 path_to_id: &FxHashMap<&Path, FileId>,
812 workspace_roots: &FxHashMap<&str, &Path>,
813) -> Option<FileId> {
814 let components: Vec<&str> = path
816 .components()
817 .filter_map(|c| match c {
818 std::path::Component::Normal(s) => s.to_str(),
819 _ => None,
820 })
821 .collect();
822
823 let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
825
826 let after_pnpm = &components[pnpm_idx + 1..];
829
830 let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
832 let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
833
834 if after_inner_nm.is_empty() {
835 return None;
836 }
837
838 let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
840 if after_inner_nm.len() >= 2 {
841 (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
842 } else {
843 return None;
844 }
845 } else {
846 (after_inner_nm[0].to_string(), 1)
847 };
848
849 let ws_root = workspace_roots.get(pkg_name.as_str())?;
851
852 let relative_parts = &after_inner_nm[pkg_name_components..];
854 if relative_parts.is_empty() {
855 return None;
856 }
857
858 let relative_path: PathBuf = relative_parts.iter().collect();
859
860 let direct = ws_root.join(&relative_path);
862 if let Some(&file_id) = path_to_id.get(direct.as_path()) {
863 return Some(file_id);
864 }
865
866 try_source_fallback(&direct, path_to_id)
868}
869
870pub(super) fn try_workspace_package_fallback(
892 ctx: &ResolveContext<'_>,
893 specifier: &str,
894) -> Option<ResolveResult> {
895 if !super::path_info::is_bare_specifier(specifier) {
897 return None;
898 }
899 let pkg_name = super::path_info::extract_package_name(specifier);
900
901 let subpath = specifier
904 .strip_prefix(pkg_name.as_str())
905 .and_then(|s| s.strip_prefix('/'))
906 .unwrap_or("");
907 let source_subpath = PathBuf::from(subpath);
908
909 if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
910 let export_key = if subpath.is_empty() {
911 ".".to_string()
912 } else {
913 format!("./{subpath}")
914 };
915 if let Some(exports) = manifest.package_json.exports.as_ref() {
916 match package_map_target(exports, &export_key, ctx.condition_names) {
917 PackageMapTarget::Target(target) => {
918 if let Some(file_id) = resolve_package_map_target(
919 ctx,
920 manifest,
921 &target,
922 Some(source_subpath.as_path()),
923 ) {
924 return Some(ResolveResult::InternalPackageModule {
925 file_id,
926 package_name: pkg_name,
927 });
928 }
929 }
930 PackageMapTarget::NoMatch | PackageMapTarget::Blocked => return None,
931 }
932 }
933 }
934
935 let (ws_root, package_name) =
936 if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
937 (manifest.root.as_path(), pkg_name)
938 } else {
939 (*ctx.workspace_roots.get(pkg_name.as_str())?, pkg_name)
940 };
941
942 let root_file = ws_root.join("__fallow_ws_self_resolve__");
945 let rel_spec = if subpath.is_empty() {
946 "./".to_string()
947 } else {
948 format!("./{subpath}")
949 };
950
951 let resolved = ctx.resolver.resolve_file(&root_file, &rel_spec).ok()?;
952 let resolved_path = resolved.path();
953
954 if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
955 return Some(ResolveResult::InternalPackageModule {
956 file_id,
957 package_name,
958 });
959 }
960 if let Ok(canonical) = dunce::canonicalize(resolved_path) {
961 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
962 return Some(ResolveResult::InternalPackageModule {
963 file_id,
964 package_name,
965 });
966 }
967 if let Some(fallback) = ctx.canonical_fallback
968 && let Some(file_id) = fallback.get(&canonical)
969 {
970 return Some(ResolveResult::InternalPackageModule {
971 file_id,
972 package_name,
973 });
974 }
975 if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
976 return Some(ResolveResult::InternalPackageModule {
977 file_id,
978 package_name,
979 });
980 }
981 }
982 None
983}
984
985pub(super) fn make_glob_from_pattern(
987 pattern: &fallow_types::extract::DynamicImportPattern,
988) -> String {
989 if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
991 return pattern.prefix.clone();
992 }
993 pattern.suffix.as_ref().map_or_else(
994 || format!("{}*", pattern.prefix),
995 |suffix| format!("{}*{}", pattern.prefix, suffix),
996 )
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002 use rustc_hash::FxHashSet;
1003
1004 #[test]
1005 fn test_extract_package_name_from_node_modules_path_regular() {
1006 let path = PathBuf::from("/project/node_modules/react/index.js");
1007 assert_eq!(
1008 extract_package_name_from_node_modules_path(&path),
1009 Some("react".to_string())
1010 );
1011 }
1012
1013 #[test]
1014 fn test_extract_package_name_from_node_modules_path_scoped() {
1015 let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
1016 assert_eq!(
1017 extract_package_name_from_node_modules_path(&path),
1018 Some("@babel/core".to_string())
1019 );
1020 }
1021
1022 #[test]
1023 fn test_extract_package_name_from_node_modules_path_nested() {
1024 let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
1026 assert_eq!(
1027 extract_package_name_from_node_modules_path(&path),
1028 Some("pkg-b".to_string())
1029 );
1030 }
1031
1032 #[test]
1033 fn test_extract_package_name_from_node_modules_path_deep_subpath() {
1034 let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
1035 assert_eq!(
1036 extract_package_name_from_node_modules_path(&path),
1037 Some("react-dom".to_string())
1038 );
1039 }
1040
1041 #[test]
1042 fn test_extract_package_name_from_node_modules_path_no_node_modules() {
1043 let path = PathBuf::from("/project/src/components/Button.tsx");
1044 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1045 }
1046
1047 #[test]
1048 fn test_extract_package_name_from_node_modules_path_just_node_modules() {
1049 let path = PathBuf::from("/project/node_modules");
1050 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1051 }
1052
1053 #[test]
1054 fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
1055 let path = PathBuf::from("/project/node_modules/@scope");
1057 assert_eq!(
1058 extract_package_name_from_node_modules_path(&path),
1059 Some("@scope".to_string())
1060 );
1061 }
1062
1063 #[test]
1064 fn test_resolve_specifier_node_modules_returns_npm_package() {
1065 let path =
1071 PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
1072 assert_eq!(
1073 extract_package_name_from_node_modules_path(&path),
1074 Some("styled-components".to_string())
1075 );
1076
1077 let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
1078 assert_eq!(
1079 extract_package_name_from_node_modules_path(&path),
1080 Some("next".to_string())
1081 );
1082 }
1083
1084 #[test]
1085 fn test_try_source_fallback_dist_to_src() {
1086 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1087 let mut path_to_id = FxHashMap::default();
1088 path_to_id.insert(src_path.as_path(), FileId(0));
1089
1090 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1091 assert_eq!(
1092 try_source_fallback(&dist_path, &path_to_id),
1093 Some(FileId(0)),
1094 "dist/utils.js should fall back to src/utils.ts"
1095 );
1096 }
1097
1098 #[test]
1099 fn test_try_source_fallback_build_to_src() {
1100 let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1101 let mut path_to_id = FxHashMap::default();
1102 path_to_id.insert(src_path.as_path(), FileId(1));
1103
1104 let build_path = PathBuf::from("/project/packages/core/build/index.js");
1105 assert_eq!(
1106 try_source_fallback(&build_path, &path_to_id),
1107 Some(FileId(1)),
1108 "build/index.js should fall back to src/index.tsx"
1109 );
1110 }
1111
1112 #[test]
1113 fn test_try_source_fallback_no_match() {
1114 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1115
1116 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1117 assert_eq!(
1118 try_source_fallback(&dist_path, &path_to_id),
1119 None,
1120 "should return None when no source file exists"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_try_source_fallback_non_output_dir() {
1126 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1127 let mut path_to_id = FxHashMap::default();
1128 path_to_id.insert(src_path.as_path(), FileId(0));
1129
1130 let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1132 assert_eq!(
1133 try_source_fallback(&normal_path, &path_to_id),
1134 None,
1135 "non-output directory path should not trigger fallback"
1136 );
1137 }
1138
1139 #[test]
1140 fn test_try_source_fallback_nested_path() {
1141 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1142 let mut path_to_id = FxHashMap::default();
1143 path_to_id.insert(src_path.as_path(), FileId(2));
1144
1145 let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1146 assert_eq!(
1147 try_source_fallback(&dist_path, &path_to_id),
1148 Some(FileId(2)),
1149 "nested dist path should fall back to nested src path"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_try_source_fallback_nested_dist_esm() {
1155 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1156 let mut path_to_id = FxHashMap::default();
1157 path_to_id.insert(src_path.as_path(), FileId(0));
1158
1159 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1160 assert_eq!(
1161 try_source_fallback(&dist_path, &path_to_id),
1162 Some(FileId(0)),
1163 "dist/esm/utils.mjs should fall back to src/utils.ts"
1164 );
1165 }
1166
1167 #[test]
1168 fn test_try_source_fallback_nested_build_cjs() {
1169 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1170 let mut path_to_id = FxHashMap::default();
1171 path_to_id.insert(src_path.as_path(), FileId(1));
1172
1173 let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1174 assert_eq!(
1175 try_source_fallback(&build_path, &path_to_id),
1176 Some(FileId(1)),
1177 "build/cjs/index.cjs should fall back to src/index.ts"
1178 );
1179 }
1180
1181 #[test]
1182 fn test_try_source_fallback_nested_dist_esm_deep_path() {
1183 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1184 let mut path_to_id = FxHashMap::default();
1185 path_to_id.insert(src_path.as_path(), FileId(2));
1186
1187 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1188 assert_eq!(
1189 try_source_fallback(&dist_path, &path_to_id),
1190 Some(FileId(2)),
1191 "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_try_source_fallback_triple_nested_output_dirs() {
1197 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1198 let mut path_to_id = FxHashMap::default();
1199 path_to_id.insert(src_path.as_path(), FileId(0));
1200
1201 let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1202 assert_eq!(
1203 try_source_fallback(&dist_path, &path_to_id),
1204 Some(FileId(0)),
1205 "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_try_source_fallback_parent_dir_named_build() {
1211 let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1212 let mut path_to_id = FxHashMap::default();
1213 path_to_id.insert(src_path.as_path(), FileId(0));
1214
1215 let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1216 assert_eq!(
1217 try_source_fallback(&dist_path, &path_to_id),
1218 Some(FileId(0)),
1219 "should resolve dist/ within project, not match parent 'build' dir"
1220 );
1221 }
1222
1223 #[test]
1224 fn package_map_exact_entry_beats_pattern_entry() {
1225 let map = serde_json::json!({
1226 "#nitro/runtime/task": "./dist/special/task.mjs",
1227 "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1228 });
1229 assert_eq!(
1230 package_map_target(&map, "#nitro/runtime/task", &conditions()),
1231 PackageMapTarget::Target("./dist/special/task.mjs".to_string())
1232 );
1233 }
1234
1235 #[test]
1236 fn package_map_wildcard_substitutes_capture() {
1237 let map = serde_json::json!({
1238 "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1239 });
1240 assert_eq!(
1241 package_map_target(&map, "#nitro/runtime/task", &conditions()),
1242 PackageMapTarget::Target("./dist/runtime/internal/task.mjs".to_string())
1243 );
1244 }
1245
1246 #[test]
1247 fn package_map_exact_entry_with_no_target_blocks_pattern_entry() {
1248 let map = serde_json::json!({
1249 "#nitro/runtime/task": null,
1250 "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1251 });
1252 assert_eq!(
1253 package_map_target(&map, "#nitro/runtime/task", &conditions()),
1254 PackageMapTarget::Blocked
1255 );
1256 }
1257
1258 #[test]
1259 fn package_map_best_pattern_with_no_target_blocks_broader_pattern() {
1260 let map = serde_json::json!({
1261 "#nitro/runtime/internal/*": null,
1262 "#nitro/runtime/*": "./dist/runtime/*.mjs"
1263 });
1264 assert_eq!(
1265 package_map_target(&map, "#nitro/runtime/internal/task", &conditions()),
1266 PackageMapTarget::Blocked
1267 );
1268 }
1269
1270 #[test]
1271 fn package_map_unmatched_subpath_is_not_a_target() {
1272 let map = serde_json::json!({
1273 "./query": "./dist/query/index.js"
1274 });
1275 assert_eq!(
1276 package_map_target(&map, "./private", &conditions()),
1277 PackageMapTarget::NoMatch
1278 );
1279 }
1280
1281 #[test]
1282 fn package_map_nested_conditions_follow_manifest_order() {
1283 let map = serde_json::json!({
1284 "./query/react": {
1285 "types": "./dist/query/react/index.d.ts",
1286 "import": {
1287 "development": "./src/query/react/index.ts",
1288 "default": "./dist/query/react/index.js"
1289 },
1290 "default": "./dist/query/react/index.cjs"
1291 }
1292 });
1293 assert_eq!(
1294 package_map_target(&map, "./query/react", &conditions()),
1295 PackageMapTarget::Target("./dist/query/react/index.d.ts".to_string())
1296 );
1297 }
1298
1299 #[test]
1300 fn package_map_import_before_types_selects_runtime_branch() {
1301 let map = serde_json::json!({
1302 ".": {
1303 "import": "./dist/index.js",
1304 "types": "./dist/index.d.ts"
1305 }
1306 });
1307 assert_eq!(
1308 package_map_target(&map, ".", &conditions()),
1309 PackageMapTarget::Target("./dist/index.js".to_string())
1310 );
1311 }
1312
1313 #[test]
1314 fn package_map_condition_order_follows_manifest_order() {
1315 let map = serde_json::json!({
1316 ".": {
1317 "node": "./dist/node.js",
1318 "import": "./dist/index.js"
1319 }
1320 });
1321 assert_eq!(
1322 package_map_target(&map, ".", &conditions()),
1323 PackageMapTarget::Target("./dist/node.js".to_string())
1324 );
1325 }
1326
1327 #[test]
1328 fn package_map_unsupported_shapes_are_skipped() {
1329 let map = serde_json::json!({
1330 "#array": ["./dist/array.js"],
1331 "#null": null,
1332 "#false": false
1333 });
1334 assert_eq!(
1335 package_map_target(&map, "#array", &conditions()),
1336 PackageMapTarget::Blocked
1337 );
1338 assert_eq!(
1339 package_map_target(&map, "#null", &conditions()),
1340 PackageMapTarget::Blocked
1341 );
1342 assert_eq!(
1343 package_map_target(&map, "#false", &conditions()),
1344 PackageMapTarget::Blocked
1345 );
1346 }
1347
1348 #[test]
1349 fn package_map_non_relative_target_does_not_trigger_source_fallback() {
1350 let root = PathBuf::from("/project");
1351 let manifest = PackageManifestInfo {
1352 root: root.clone(),
1353 canonical_root: root,
1354 name: Some("pkg".to_string()),
1355 package_json: fallow_config::PackageJson::default(),
1356 };
1357 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1358 let raw_path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1359 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1360 let condition_names = conditions();
1361 let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
1362 let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
1363 let ctx = ResolveContext {
1364 resolver: &resolver,
1365 style_resolver: &resolver,
1366 extensions: &[],
1367 path_to_id: &path_to_id,
1368 raw_path_to_id: &raw_path_to_id,
1369 workspace_roots: &workspace_roots,
1370 package_manifests: std::slice::from_ref(&manifest),
1371 condition_names: &condition_names,
1372 path_aliases: &[],
1373 scss_include_paths: &[],
1374 root: &manifest.root,
1375 canonical_fallback: None,
1376 tsconfig_warned: &tsconfig_warned,
1377 };
1378
1379 assert!(resolve_package_map_target(&ctx, &manifest, "lodash", None).is_none());
1380 assert!(resolve_package_map_target(&ctx, &manifest, "../dist/index.js", None).is_none());
1381 }
1382
1383 #[test]
1384 fn package_imports_fallback_supports_unnamed_packages() {
1385 let root = PathBuf::from("/project");
1386 let src_path = root.join("src/runtime/task.ts");
1387 let manifest = PackageManifestInfo {
1388 root: root.clone(),
1389 canonical_root: root.clone(),
1390 name: None,
1391 package_json: fallow_config::PackageJson {
1392 imports: Some(serde_json::json!({
1393 "#runtime/*": "./dist/runtime/*.mjs"
1394 })),
1395 ..Default::default()
1396 },
1397 };
1398 let mut raw_path_to_id = FxHashMap::default();
1399 raw_path_to_id.insert(src_path.as_path(), FileId(7));
1400 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1401 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1402 let condition_names = conditions();
1403 let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
1404 let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
1405 let ctx = ResolveContext {
1406 resolver: &resolver,
1407 style_resolver: &resolver,
1408 extensions: &[],
1409 path_to_id: &path_to_id,
1410 raw_path_to_id: &raw_path_to_id,
1411 workspace_roots: &workspace_roots,
1412 package_manifests: std::slice::from_ref(&manifest),
1413 condition_names: &condition_names,
1414 path_aliases: &[],
1415 scss_include_paths: &[],
1416 root: &manifest.root,
1417 canonical_fallback: None,
1418 tsconfig_warned: &tsconfig_warned,
1419 };
1420
1421 let result =
1422 try_package_imports_fallback(&ctx, &root.join("src/index.ts"), "#runtime/task");
1423 assert!(matches!(
1424 result,
1425 Some(ResolveResult::InternalModule(FileId(7)))
1426 ));
1427 }
1428
1429 #[test]
1430 fn test_pnpm_store_path_extract_package_name() {
1431 let path =
1433 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1434 assert_eq!(
1435 extract_package_name_from_node_modules_path(&path),
1436 Some("react".to_string())
1437 );
1438 }
1439
1440 #[test]
1441 fn test_pnpm_store_path_scoped_package() {
1442 let path = PathBuf::from(
1443 "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1444 );
1445 assert_eq!(
1446 extract_package_name_from_node_modules_path(&path),
1447 Some("@babel/core".to_string())
1448 );
1449 }
1450
1451 fn conditions() -> Vec<String> {
1452 vec![
1453 "development".to_string(),
1454 "import".to_string(),
1455 "require".to_string(),
1456 "default".to_string(),
1457 "types".to_string(),
1458 "node".to_string(),
1459 ]
1460 }
1461
1462 #[test]
1463 fn test_pnpm_store_path_with_peer_deps() {
1464 let path = PathBuf::from(
1465 "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
1466 );
1467 assert_eq!(
1468 extract_package_name_from_node_modules_path(&path),
1469 Some("webpack".to_string())
1470 );
1471 }
1472
1473 #[test]
1474 fn test_try_pnpm_workspace_fallback_dist_to_src() {
1475 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1476 let mut path_to_id = FxHashMap::default();
1477 path_to_id.insert(src_path.as_path(), FileId(0));
1478
1479 let mut workspace_roots = FxHashMap::default();
1480 let ws_root = PathBuf::from("/project/packages/ui");
1481 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1482
1483 let pnpm_path = PathBuf::from(
1485 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
1486 );
1487 assert_eq!(
1488 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1489 Some(FileId(0)),
1490 ".pnpm workspace path should fall back to src/utils.ts"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_try_pnpm_workspace_fallback_direct_source() {
1496 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1497 let mut path_to_id = FxHashMap::default();
1498 path_to_id.insert(src_path.as_path(), FileId(1));
1499
1500 let mut workspace_roots = FxHashMap::default();
1501 let ws_root = PathBuf::from("/project/packages/core");
1502 workspace_roots.insert("@myorg/core", ws_root.as_path());
1503
1504 let pnpm_path = PathBuf::from(
1506 "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
1507 );
1508 assert_eq!(
1509 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1510 Some(FileId(1)),
1511 ".pnpm workspace path with src/ should resolve directly"
1512 );
1513 }
1514
1515 #[test]
1516 fn test_try_pnpm_workspace_fallback_non_workspace_package() {
1517 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1518
1519 let mut workspace_roots = FxHashMap::default();
1520 let ws_root = PathBuf::from("/project/packages/ui");
1521 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1522
1523 let pnpm_path =
1525 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1526 assert_eq!(
1527 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1528 None,
1529 "non-workspace package in .pnpm should return None"
1530 );
1531 }
1532
1533 #[test]
1534 fn test_try_pnpm_workspace_fallback_unscoped_package() {
1535 let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
1536 let mut path_to_id = FxHashMap::default();
1537 path_to_id.insert(src_path.as_path(), FileId(2));
1538
1539 let mut workspace_roots = FxHashMap::default();
1540 let ws_root = PathBuf::from("/project/packages/utils");
1541 workspace_roots.insert("my-utils", ws_root.as_path());
1542
1543 let pnpm_path = PathBuf::from(
1545 "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
1546 );
1547 assert_eq!(
1548 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1549 Some(FileId(2)),
1550 "unscoped workspace package in .pnpm should resolve"
1551 );
1552 }
1553
1554 #[test]
1555 fn test_try_pnpm_workspace_fallback_nested_path() {
1556 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1557 let mut path_to_id = FxHashMap::default();
1558 path_to_id.insert(src_path.as_path(), FileId(3));
1559
1560 let mut workspace_roots = FxHashMap::default();
1561 let ws_root = PathBuf::from("/project/packages/ui");
1562 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1563
1564 let pnpm_path = PathBuf::from(
1566 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1567 );
1568 assert_eq!(
1569 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1570 Some(FileId(3)),
1571 "nested .pnpm workspace path should resolve through source fallback"
1572 );
1573 }
1574
1575 #[test]
1576 fn test_try_pnpm_workspace_fallback_no_pnpm() {
1577 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1578 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1579
1580 let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1582 assert_eq!(
1583 try_pnpm_workspace_fallback(®ular_path, &path_to_id, &workspace_roots),
1584 None,
1585 );
1586 }
1587
1588 #[test]
1589 fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1590 let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1591 let mut path_to_id = FxHashMap::default();
1592 path_to_id.insert(src_path.as_path(), FileId(4));
1593
1594 let mut workspace_roots = FxHashMap::default();
1595 let ws_root = PathBuf::from("/project/packages/ui");
1596 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1597
1598 let pnpm_path = PathBuf::from(
1600 "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1601 );
1602 assert_eq!(
1603 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1604 Some(FileId(4)),
1605 ".pnpm path with peer dep suffix should still resolve"
1606 );
1607 }
1608
1609 #[test]
1612 fn make_glob_prefix_only_no_suffix() {
1613 let pattern = fallow_types::extract::DynamicImportPattern {
1614 prefix: "./locales/".to_string(),
1615 suffix: None,
1616 span: oxc_span::Span::default(),
1617 };
1618 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*");
1619 }
1620
1621 #[test]
1622 fn make_glob_prefix_with_suffix() {
1623 let pattern = fallow_types::extract::DynamicImportPattern {
1624 prefix: "./locales/".to_string(),
1625 suffix: Some(".json".to_string()),
1626 span: oxc_span::Span::default(),
1627 };
1628 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1629 }
1630
1631 #[test]
1632 fn make_glob_passthrough_star() {
1633 let pattern = fallow_types::extract::DynamicImportPattern {
1635 prefix: "./pages/**/*.tsx".to_string(),
1636 suffix: None,
1637 span: oxc_span::Span::default(),
1638 };
1639 assert_eq!(make_glob_from_pattern(&pattern), "./pages/**/*.tsx");
1640 }
1641
1642 #[test]
1643 fn make_glob_passthrough_brace() {
1644 let pattern = fallow_types::extract::DynamicImportPattern {
1645 prefix: "./i18n/{en,de,fr}.json".to_string(),
1646 suffix: None,
1647 span: oxc_span::Span::default(),
1648 };
1649 assert_eq!(make_glob_from_pattern(&pattern), "./i18n/{en,de,fr}.json");
1650 }
1651
1652 #[test]
1653 fn make_glob_empty_prefix_no_suffix() {
1654 let pattern = fallow_types::extract::DynamicImportPattern {
1655 prefix: String::new(),
1656 suffix: None,
1657 span: oxc_span::Span::default(),
1658 };
1659 assert_eq!(make_glob_from_pattern(&pattern), "*");
1660 }
1661
1662 #[test]
1663 fn make_glob_empty_prefix_with_suffix() {
1664 let pattern = fallow_types::extract::DynamicImportPattern {
1665 prefix: String::new(),
1666 suffix: Some(".ts".to_string()),
1667 span: oxc_span::Span::default(),
1668 };
1669 assert_eq!(make_glob_from_pattern(&pattern), "*.ts");
1670 }
1671
1672 #[test]
1675 fn make_glob_template_literal_prefix_only() {
1676 let pattern = fallow_types::extract::DynamicImportPattern {
1678 prefix: "./pages/".to_string(),
1679 suffix: None,
1680 span: oxc_span::Span::default(),
1681 };
1682 assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1683 }
1684
1685 #[test]
1686 fn make_glob_template_literal_with_extension_suffix() {
1687 let pattern = fallow_types::extract::DynamicImportPattern {
1689 prefix: "./locales/".to_string(),
1690 suffix: Some(".json".to_string()),
1691 span: oxc_span::Span::default(),
1692 };
1693 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1694 }
1695
1696 #[test]
1697 fn make_glob_template_literal_deep_prefix() {
1698 let pattern = fallow_types::extract::DynamicImportPattern {
1701 prefix: "./modules/".to_string(),
1702 suffix: None,
1703 span: oxc_span::Span::default(),
1704 };
1705 assert_eq!(make_glob_from_pattern(&pattern), "./modules/*");
1706 }
1707
1708 #[test]
1709 fn make_glob_string_concat_prefix() {
1710 let pattern = fallow_types::extract::DynamicImportPattern {
1712 prefix: "./pages/".to_string(),
1713 suffix: None,
1714 span: oxc_span::Span::default(),
1715 };
1716 assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1717 }
1718
1719 #[test]
1720 fn make_glob_string_concat_with_extension() {
1721 let pattern = fallow_types::extract::DynamicImportPattern {
1723 prefix: "./views/".to_string(),
1724 suffix: Some(".vue".to_string()),
1725 span: oxc_span::Span::default(),
1726 };
1727 assert_eq!(make_glob_from_pattern(&pattern), "./views/*.vue");
1728 }
1729
1730 #[test]
1733 fn make_glob_import_meta_glob_recursive() {
1734 let pattern = fallow_types::extract::DynamicImportPattern {
1736 prefix: "./components/**/*.vue".to_string(),
1737 suffix: None,
1738 span: oxc_span::Span::default(),
1739 };
1740 assert_eq!(
1741 make_glob_from_pattern(&pattern),
1742 "./components/**/*.vue",
1743 "import.meta.glob patterns with * should pass through as-is"
1744 );
1745 }
1746
1747 #[test]
1748 fn make_glob_import_meta_glob_brace_expansion() {
1749 let pattern = fallow_types::extract::DynamicImportPattern {
1751 prefix: "./plugins/{auth,analytics}.ts".to_string(),
1752 suffix: None,
1753 span: oxc_span::Span::default(),
1754 };
1755 assert_eq!(
1756 make_glob_from_pattern(&pattern),
1757 "./plugins/{auth,analytics}.ts",
1758 "import.meta.glob patterns with braces should pass through as-is"
1759 );
1760 }
1761
1762 #[test]
1763 fn make_glob_import_meta_glob_star_with_brace() {
1764 let pattern = fallow_types::extract::DynamicImportPattern {
1766 prefix: "./routes/**/*.{ts,tsx}".to_string(),
1767 suffix: None,
1768 span: oxc_span::Span::default(),
1769 };
1770 assert_eq!(
1771 make_glob_from_pattern(&pattern),
1772 "./routes/**/*.{ts,tsx}",
1773 "combined * and brace patterns should pass through"
1774 );
1775 }
1776
1777 #[test]
1778 fn make_glob_import_meta_glob_ignores_suffix_when_star_present() {
1779 let pattern = fallow_types::extract::DynamicImportPattern {
1781 prefix: "./*.ts".to_string(),
1782 suffix: Some(".extra".to_string()),
1783 span: oxc_span::Span::default(),
1784 };
1785 assert_eq!(
1786 make_glob_from_pattern(&pattern),
1787 "./*.ts",
1788 "when prefix has glob chars, suffix is ignored (prefix used as-is)"
1789 );
1790 }
1791
1792 #[test]
1795 fn make_glob_single_dot_prefix() {
1796 let pattern = fallow_types::extract::DynamicImportPattern {
1797 prefix: "./".to_string(),
1798 suffix: None,
1799 span: oxc_span::Span::default(),
1800 };
1801 assert_eq!(make_glob_from_pattern(&pattern), "./*");
1802 }
1803
1804 #[test]
1805 fn make_glob_prefix_without_trailing_slash() {
1806 let pattern = fallow_types::extract::DynamicImportPattern {
1808 prefix: "./config".to_string(),
1809 suffix: None,
1810 span: oxc_span::Span::default(),
1811 };
1812 assert_eq!(make_glob_from_pattern(&pattern), "./config*");
1813 }
1814
1815 #[test]
1816 fn make_glob_prefix_with_dotdot() {
1817 let pattern = fallow_types::extract::DynamicImportPattern {
1818 prefix: "../shared/".to_string(),
1819 suffix: Some(".ts".to_string()),
1820 span: oxc_span::Span::default(),
1821 };
1822 assert_eq!(make_glob_from_pattern(&pattern), "../shared/*.ts");
1823 }
1824
1825 #[test]
1828 fn test_extract_package_name_with_pnpm_plus_encoded_scope() {
1829 let path = PathBuf::from(
1832 "/project/node_modules/.pnpm/@mui+material@5.15.0/node_modules/@mui/material/index.js",
1833 );
1834 assert_eq!(
1835 extract_package_name_from_node_modules_path(&path),
1836 Some("@mui/material".to_string())
1837 );
1838 }
1839
1840 #[test]
1841 fn test_extract_package_name_windows_style_path() {
1842 let path = PathBuf::from("/project/node_modules/typescript/lib/tsc.js");
1844 assert_eq!(
1845 extract_package_name_from_node_modules_path(&path),
1846 Some("typescript".to_string())
1847 );
1848 }
1849
1850 #[test]
1853 fn test_try_source_fallback_out_dir() {
1854 let src_path = PathBuf::from("/project/packages/api/src/handler.ts");
1855 let mut path_to_id = FxHashMap::default();
1856 path_to_id.insert(src_path.as_path(), FileId(5));
1857
1858 let out_path = PathBuf::from("/project/packages/api/out/handler.js");
1859 assert_eq!(
1860 try_source_fallback(&out_path, &path_to_id),
1861 Some(FileId(5)),
1862 "out/handler.js should fall back to src/handler.ts"
1863 );
1864 }
1865
1866 #[test]
1867 fn test_try_source_fallback_mts_extension() {
1868 let src_path = PathBuf::from("/project/packages/lib/src/utils.mts");
1869 let mut path_to_id = FxHashMap::default();
1870 path_to_id.insert(src_path.as_path(), FileId(6));
1871
1872 let dist_path = PathBuf::from("/project/packages/lib/dist/utils.mjs");
1873 assert_eq!(
1874 try_source_fallback(&dist_path, &path_to_id),
1875 Some(FileId(6)),
1876 "dist/utils.mjs should fall back to src/utils.mts"
1877 );
1878 }
1879
1880 #[test]
1881 fn test_try_source_fallback_cts_extension() {
1882 let src_path = PathBuf::from("/project/packages/lib/src/config.cts");
1883 let mut path_to_id = FxHashMap::default();
1884 path_to_id.insert(src_path.as_path(), FileId(7));
1885
1886 let dist_path = PathBuf::from("/project/packages/lib/dist/config.cjs");
1887 assert_eq!(
1888 try_source_fallback(&dist_path, &path_to_id),
1889 Some(FileId(7)),
1890 "dist/config.cjs should fall back to src/config.cts"
1891 );
1892 }
1893
1894 #[test]
1895 fn test_try_source_fallback_jsx_extension() {
1896 let src_path = PathBuf::from("/project/packages/ui/src/App.jsx");
1897 let mut path_to_id = FxHashMap::default();
1898 path_to_id.insert(src_path.as_path(), FileId(8));
1899
1900 let build_path = PathBuf::from("/project/packages/ui/build/App.js");
1901 assert_eq!(
1902 try_source_fallback(&build_path, &path_to_id),
1903 Some(FileId(8)),
1904 "build/App.js should fall back to src/App.jsx"
1905 );
1906 }
1907
1908 #[test]
1909 fn test_try_source_fallback_no_file_stem() {
1910 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1912 let dist_path = PathBuf::from("/project/packages/ui/dist/");
1913 assert_eq!(
1914 try_source_fallback(&dist_path, &path_to_id),
1915 None,
1916 "directory path with no file should return None"
1917 );
1918 }
1919
1920 #[test]
1921 fn test_try_source_fallback_esm_subdir() {
1922 let src_path = PathBuf::from("/project/lib/src/index.ts");
1924 let mut path_to_id = FxHashMap::default();
1925 path_to_id.insert(src_path.as_path(), FileId(10));
1926
1927 let dist_path = PathBuf::from("/project/lib/esm/index.mjs");
1928 assert_eq!(
1929 try_source_fallback(&dist_path, &path_to_id),
1930 Some(FileId(10)),
1931 "standalone esm/ directory should fall back to src/"
1932 );
1933 }
1934
1935 #[test]
1936 fn test_try_source_fallback_cjs_subdir() {
1937 let src_path = PathBuf::from("/project/lib/src/index.ts");
1938 let mut path_to_id = FxHashMap::default();
1939 path_to_id.insert(src_path.as_path(), FileId(11));
1940
1941 let cjs_path = PathBuf::from("/project/lib/cjs/index.cjs");
1942 assert_eq!(
1943 try_source_fallback(&cjs_path, &path_to_id),
1944 Some(FileId(11)),
1945 "standalone cjs/ directory should fall back to src/"
1946 );
1947 }
1948
1949 #[test]
1952 fn test_try_pnpm_workspace_fallback_empty_after_pnpm() {
1953 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1955 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1956
1957 let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/node_modules");
1958 assert_eq!(
1959 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1960 None,
1961 "path ending at node_modules with nothing after should return None"
1962 );
1963 }
1964
1965 #[test]
1966 fn test_try_pnpm_workspace_fallback_scoped_package_only_scope() {
1967 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1969 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1970
1971 let pnpm_path =
1972 PathBuf::from("/project/node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope");
1973 assert_eq!(
1974 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1975 None,
1976 "scoped package without full name and no matching workspace should return None"
1977 );
1978 }
1979
1980 #[test]
1981 fn test_try_pnpm_workspace_fallback_no_inner_node_modules() {
1982 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1984 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1985
1986 let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/dist/index.js");
1987 assert_eq!(
1988 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1989 None,
1990 "path without inner node_modules after .pnpm should return None"
1991 );
1992 }
1993
1994 #[test]
1995 fn test_try_pnpm_workspace_fallback_package_without_relative_path() {
1996 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1998 let mut workspace_roots = FxHashMap::default();
1999 let ws_root = PathBuf::from("/project/packages/ui");
2000 workspace_roots.insert("@myorg/ui", ws_root.as_path());
2001
2002 let pnpm_path =
2003 PathBuf::from("/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui");
2004 assert_eq!(
2005 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2006 None,
2007 "path ending at package name with no relative file should return None"
2008 );
2009 }
2010
2011 #[test]
2012 fn test_try_pnpm_workspace_fallback_nested_dist_esm() {
2013 let src_path = PathBuf::from("/project/packages/ui/src/Button.ts");
2014 let mut path_to_id = FxHashMap::default();
2015 path_to_id.insert(src_path.as_path(), FileId(10));
2016
2017 let mut workspace_roots = FxHashMap::default();
2018 let ws_root = PathBuf::from("/project/packages/ui");
2019 workspace_roots.insert("@myorg/ui", ws_root.as_path());
2020
2021 let pnpm_path = PathBuf::from(
2023 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/esm/Button.mjs",
2024 );
2025 assert_eq!(
2026 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2027 Some(FileId(10)),
2028 "pnpm path with nested dist/esm should resolve through source fallback"
2029 );
2030 }
2031}