1use std::path::{Path, PathBuf};
7
8use rustc_hash::FxHashMap;
9use serde_json::Value;
10
11use fallow_types::discover::FileId;
12
13use super::path_info::{extract_package_name, is_bare_specifier, is_valid_package_name};
14use super::types::{OUTPUT_DIRS, PackageManifestInfo, ResolveContext, ResolveResult, SOURCE_EXTS};
15
16fn alias_match_remainder<'a>(specifier: &'a str, prefix: &str) -> Option<&'a str> {
25 let remainder = specifier.strip_prefix(prefix)?;
26 (remainder.is_empty() || prefix.ends_with('/') || remainder.starts_with('/'))
27 .then_some(remainder)
28}
29
30pub(super) fn try_path_alias_fallback(
37 ctx: &ResolveContext<'_>,
38 specifier: &str,
39) -> Option<ResolveResult> {
40 for (prefix, replacement) in ctx.path_aliases {
41 let Some(remainder) = alias_match_remainder(specifier, prefix) else {
42 continue;
43 };
44
45 let substituted = match (replacement.is_empty(), remainder.is_empty()) {
51 (true, _) => format!("./{remainder}"),
52 (false, true) => format!("./{replacement}"),
53 (false, false) => format!("./{replacement}/{remainder}"),
54 };
55
56 if let Ok(resolved) = ctx.resolver.resolve(ctx.root, &substituted) {
61 let resolved_path = resolved.path();
62 if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
64 return Some(ResolveResult::InternalModule(file_id));
65 }
66 if let Ok(canonical) = dunce::canonicalize(resolved_path) {
68 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
69 return Some(ResolveResult::InternalModule(file_id));
70 }
71 if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
72 return Some(ResolveResult::InternalModule(file_id));
73 }
74 if let Some(file_id) =
75 try_pnpm_workspace_fallback(&canonical, ctx.path_to_id, ctx.workspace_roots)
76 {
77 return Some(ResolveResult::InternalModule(file_id));
78 }
79 if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
80 return Some(ResolveResult::NpmPackage(pkg_name));
81 }
82 return Some(ResolveResult::ExternalFile(canonical));
83 }
84 }
85 }
86 None
87}
88
89pub(super) fn try_scss_partial_fallback(
98 ctx: &ResolveContext<'_>,
99 from_file: &Path,
100 specifier: &str,
101) -> Option<ResolveResult> {
102 if specifier.contains(':') {
104 return None;
105 }
106
107 let spec_path = Path::new(specifier);
108 let filename = spec_path.file_name()?.to_str()?;
109
110 if filename.starts_with('_') {
112 return None;
113 }
114
115 let partial_filename = format!("_{filename}");
117 let partial_specifier = if let Some(parent) = spec_path.parent()
118 && !parent.as_os_str().is_empty()
119 {
120 format!("{}/{partial_filename}", parent.display())
121 } else {
122 partial_filename
123 };
124
125 if let Some(result) = try_resolve_scss(ctx, from_file, &partial_specifier) {
126 return Some(result);
127 }
128
129 let index_partial = format!("{specifier}/_index");
131 if let Some(result) = try_resolve_scss(ctx, from_file, &index_partial) {
132 return Some(result);
133 }
134
135 let index_plain = format!("{specifier}/index");
136 try_resolve_scss(ctx, from_file, &index_plain)
137}
138
139pub(super) fn try_css_extension_fallback(
149 ctx: &ResolveContext<'_>,
150 from_file: &Path,
151 specifier: &str,
152) -> Option<ResolveResult> {
153 if specifier.contains(':') {
154 return None;
155 }
156 let spec_path = Path::new(specifier);
160 let already_css_ext = spec_path
161 .extension()
162 .and_then(|e| e.to_str())
163 .is_some_and(|e| {
164 e.eq_ignore_ascii_case("css")
165 || e.eq_ignore_ascii_case("scss")
166 || e.eq_ignore_ascii_case("sass")
167 });
168 if already_css_ext {
169 return try_resolve_scss(ctx, from_file, specifier);
170 }
171 for ext in ["scss", "sass", "css"] {
172 let candidate = format!("{specifier}.{ext}");
173 if let Some(result) = try_resolve_scss(ctx, from_file, &candidate) {
174 return Some(result);
175 }
176 }
177 None
178}
179
180fn try_resolve_scss(
182 ctx: &ResolveContext<'_>,
183 from_file: &Path,
184 specifier: &str,
185) -> Option<ResolveResult> {
186 let resolved = ctx.resolver.resolve_file(from_file, specifier).ok()?;
187 let resolved_path = resolved.path();
188
189 if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
190 return Some(ResolveResult::InternalModule(file_id));
191 }
192 if let Ok(canonical) = dunce::canonicalize(resolved_path)
193 && let Some(&file_id) = ctx.path_to_id.get(canonical.as_path())
194 {
195 return Some(ResolveResult::InternalModule(file_id));
196 }
197 None
198}
199
200pub(super) fn try_scss_include_path_fallback(
220 ctx: &ResolveContext<'_>,
221 from_file: &Path,
222 specifier: &str,
223 from_style: bool,
224) -> Option<ResolveResult> {
225 if ctx.scss_include_paths.is_empty() {
226 return None;
227 }
228 let is_scss_importer = from_file
229 .extension()
230 .is_some_and(|e| e == "scss" || e == "sass");
231 if !is_scss_importer && !from_style {
232 return None;
233 }
234 if specifier.contains(':') {
236 return None;
237 }
238 let bare = specifier.strip_prefix("./")?;
242 if bare.starts_with("..") || bare.starts_with('/') {
243 return None;
244 }
245
246 for include_dir in ctx.scss_include_paths {
247 if let Some(file_id) = find_scss_in_dir(include_dir, bare, ctx) {
248 return Some(ResolveResult::InternalModule(file_id));
249 }
250 }
251 None
252}
253
254fn find_scss_in_dir(include_dir: &Path, bare: &str, ctx: &ResolveContext<'_>) -> Option<FileId> {
258 let bare_path = Path::new(bare);
259 let has_scss_ext = matches!(
260 bare_path.extension().and_then(|e| e.to_str()),
261 Some(ext) if ext.eq_ignore_ascii_case("scss") || ext.eq_ignore_ascii_case("sass")
262 );
263
264 let parent = bare_path.parent();
267 let stem_with_ext = bare_path.file_name()?.to_str()?;
268 let stem_without_ext = bare_path.file_stem().and_then(|s| s.to_str())?;
269
270 let build = |rel: &Path| -> std::path::PathBuf { include_dir.join(rel) };
271 let join_with_parent = |name: &str| -> std::path::PathBuf {
272 parent.map_or_else(|| build(Path::new(name)), |p| build(&p.join(name)))
273 };
274
275 let exts: &[&str] = if has_scss_ext {
276 &[""]
277 } else {
278 &["scss", "sass"]
279 };
280
281 for ext in exts {
282 let suffix = if ext.is_empty() {
283 String::new()
284 } else {
285 format!(".{ext}")
286 };
287 let direct = if ext.is_empty() {
289 build(bare_path)
290 } else {
291 join_with_parent(&format!("{stem_with_ext}{suffix}"))
292 };
293 if let Some(fid) = lookup_scss_path(&direct, ctx) {
294 return Some(fid);
295 }
296 let partial_name = if ext.is_empty() {
298 format!("_{stem_with_ext}")
299 } else {
300 format!("_{stem_without_ext}{suffix}")
301 };
302 let partial = join_with_parent(&partial_name);
303 if let Some(fid) = lookup_scss_path(&partial, ctx) {
304 return Some(fid);
305 }
306 if ext.is_empty() {
307 continue;
309 }
310 let idx_partial = build(bare_path).join(format!("_index{suffix}"));
312 if let Some(fid) = lookup_scss_path(&idx_partial, ctx) {
313 return Some(fid);
314 }
315 let idx_plain = build(bare_path).join(format!("index{suffix}"));
316 if let Some(fid) = lookup_scss_path(&idx_plain, ctx) {
317 return Some(fid);
318 }
319 }
320 None
321}
322
323fn lookup_scss_path(candidate: &Path, ctx: &ResolveContext<'_>) -> Option<FileId> {
326 if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
327 return Some(file_id);
328 }
329 if let Ok(canonical) = dunce::canonicalize(candidate) {
330 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
331 return Some(file_id);
332 }
333 if let Some(fallback) = ctx.canonical_fallback
334 && let Some(file_id) = fallback.get(&canonical)
335 {
336 return Some(file_id);
337 }
338 }
339 None
340}
341
342pub(super) fn try_scss_node_modules_fallback(
364 _ctx: &ResolveContext<'_>,
365 from_file: &Path,
366 specifier: &str,
367 from_style: bool,
368) -> Option<ResolveResult> {
369 if specifier.contains(':') {
371 return None;
372 }
373 let is_scss_importer = from_file
374 .extension()
375 .is_some_and(|e| e == "scss" || e == "sass");
376 if !is_scss_importer && !from_style {
377 return None;
378 }
379 let bare = specifier.strip_prefix("./")?;
383 if bare.starts_with("..") || bare.starts_with('/') {
384 return None;
385 }
386 if bare.is_empty() {
390 return None;
391 }
392
393 let mut dir = from_file.parent()?;
401 loop {
402 let nm_dir = dir.join("node_modules");
403 if nm_dir.is_dir()
404 && let Some(path) = find_scss_in_node_modules(&nm_dir, bare)
405 && let Some(pkg_name) = extract_package_name_from_node_modules_path(&path)
406 {
407 return Some(ResolveResult::NpmPackage(pkg_name));
408 }
409 let Some(parent) = dir.parent() else {
410 break;
411 };
412 dir = parent;
413 }
414 None
415}
416
417fn find_scss_in_node_modules(nm_dir: &Path, bare: &str) -> Option<PathBuf> {
426 let bare_path = Path::new(bare);
427 let file_name = bare_path.file_name()?.to_str()?;
428 let parent = bare_path.parent();
429 let join_with_parent = |name: &str| -> PathBuf {
430 parent.map_or_else(|| nm_dir.join(name), |p| nm_dir.join(p).join(name))
431 };
432
433 for ext in &["scss", "sass", "css"] {
437 let candidate = join_with_parent(&format!("{file_name}.{ext}"));
438 if candidate.is_file() {
439 return Some(candidate);
440 }
441 }
442 for ext in &["scss", "sass"] {
445 let candidate = join_with_parent(&format!("_{file_name}.{ext}"));
446 if candidate.is_file() {
447 return Some(candidate);
448 }
449 }
450 for ext in &["scss", "sass"] {
452 let idx_partial = nm_dir.join(bare).join(format!("_index.{ext}"));
453 if idx_partial.is_file() {
454 return Some(idx_partial);
455 }
456 let idx_plain = nm_dir.join(bare).join(format!("index.{ext}"));
457 if idx_plain.is_file() {
458 return Some(idx_plain);
459 }
460 }
461 let exact = nm_dir.join(bare);
464 if exact.is_file() {
465 return Some(exact);
466 }
467 None
468}
469
470pub(super) fn try_source_fallback(
482 resolved: &Path,
483 path_to_id: &FxHashMap<&Path, FileId>,
484) -> Option<FileId> {
485 let components: Vec<_> = resolved.components().collect();
486
487 let is_output_dir = |c: &std::path::Component| -> bool {
488 if let std::path::Component::Normal(s) = c
489 && let Some(name) = s.to_str()
490 {
491 return OUTPUT_DIRS.contains(&name);
492 }
493 false
494 };
495
496 let last_output_pos = components.iter().rposition(&is_output_dir)?;
500
501 let mut first_output_pos = last_output_pos;
504 while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
505 first_output_pos -= 1;
506 }
507
508 let prefix: PathBuf = components[..first_output_pos].iter().collect();
510
511 let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
513 suffix.file_stem()?; for ext in SOURCE_EXTS {
517 let source_candidate = prefix.join("src").join(suffix.with_extension(ext));
518 if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
519 return Some(file_id);
520 }
521 }
522
523 None
524}
525
526pub(super) fn try_package_imports_fallback(
532 ctx: &ResolveContext<'_>,
533 from_file: &Path,
534 specifier: &str,
535) -> Option<ResolveResult> {
536 if !specifier.starts_with('#') {
537 return None;
538 }
539 let manifest = nearest_package_manifest(ctx.package_manifests, from_file)?;
540 let imports = manifest.package_json.imports.as_ref()?;
541 let PackageMapTarget::Targets(targets) =
542 package_map_target(imports, specifier, ctx.condition_names)
543 else {
544 return None;
545 };
546 let source_subpath = package_import_source_subpath(manifest, specifier);
547 resolve_package_import_targets(ctx, manifest, &targets, source_subpath.as_deref()).map(
548 |target| match target {
549 PackageImportTarget::Internal(file_id) => match &manifest.name {
550 Some(package_name) => ResolveResult::InternalPackageModule {
551 file_id,
552 package_name: package_name.clone(),
553 },
554 None => ResolveResult::InternalModule(file_id),
555 },
556 PackageImportTarget::ExternalPackage(package_name) => {
557 ResolveResult::NpmPackage(package_name)
558 }
559 },
560 )
561}
562
563#[derive(Debug, Clone, PartialEq, Eq)]
564enum PackageMapTarget {
565 NoMatch,
566 Blocked,
567 Targets(Vec<String>),
568}
569
570enum PackageImportTarget {
571 Internal(FileId),
572 ExternalPackage(String),
573}
574
575fn package_map_match_value(
576 value: &Value,
577 condition_names: &[String],
578 capture: Option<&str>,
579) -> PackageMapTarget {
580 resolve_package_map_value(value, condition_names, capture)
581 .filter(|targets| !targets.is_empty())
582 .map_or(PackageMapTarget::Blocked, PackageMapTarget::Targets)
583}
584
585fn package_map_target(
586 map: &Value,
587 specifier_key: &str,
588 condition_names: &[String],
589) -> PackageMapTarget {
590 let Some(obj) = map.as_object() else {
591 if specifier_key == "." {
592 return package_map_match_value(map, condition_names, None);
593 }
594 return PackageMapTarget::NoMatch;
595 };
596
597 let has_subpath_keys = obj
598 .keys()
599 .any(|key| key == "." || key.starts_with("./") || key.starts_with('#'));
600 if !has_subpath_keys {
601 if specifier_key == "." {
602 return package_map_match_value(map, condition_names, None);
603 }
604 return PackageMapTarget::NoMatch;
605 }
606
607 if let Some(value) = obj.get(specifier_key) {
608 return package_map_match_value(value, condition_names, None);
609 }
610
611 let mut patterns: Vec<(&str, &Value, String)> = obj
612 .iter()
613 .filter_map(|(pattern, value)| {
614 package_map_pattern_capture(pattern, specifier_key)
615 .map(|capture| (pattern.as_str(), value, capture))
616 })
617 .collect();
618 patterns.sort_by(|(left, _, _), (right, _, _)| {
619 package_map_pattern_specificity(right).cmp(&package_map_pattern_specificity(left))
620 });
621
622 patterns
623 .first()
624 .map_or(PackageMapTarget::NoMatch, |(_, value, capture)| {
625 package_map_match_value(value, condition_names, Some(capture))
626 })
627}
628
629fn resolve_package_map_value(
630 value: &Value,
631 condition_names: &[String],
632 capture: Option<&str>,
633) -> Option<Vec<String>> {
634 match value {
635 Value::String(target) => Some(vec![match capture {
636 Some(capture) => target.replace('*', capture),
637 None => target.clone(),
638 }]),
639 Value::Object(map) => {
640 for (condition, value) in map {
641 if (condition == "default"
642 || condition_names
643 .iter()
644 .any(|active_condition| active_condition == condition))
645 && let Some(targets) =
646 resolve_package_map_value(value, condition_names, capture)
647 {
648 return Some(targets);
649 }
650 }
651 None
652 }
653 Value::Array(values) => {
654 let targets: Vec<String> = values
655 .iter()
656 .filter_map(|value| resolve_package_map_value(value, condition_names, capture))
657 .flatten()
658 .collect();
659 (!targets.is_empty()).then_some(targets)
660 }
661 Value::Bool(_) | Value::Null | Value::Number(_) => None,
662 }
663}
664
665fn package_map_pattern_capture(pattern: &str, specifier: &str) -> Option<String> {
666 let star = pattern.find('*')?;
667 if pattern[star + 1..].contains('*') {
668 return None;
669 }
670 let (prefix, suffix_with_star) = pattern.split_at(star);
671 let suffix = &suffix_with_star[1..];
672 let captured = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
673 Some(captured.to_string())
674}
675
676fn package_map_pattern_specificity(pattern: &str) -> (usize, usize) {
677 let star = pattern.find('*').unwrap_or(pattern.len());
678 (star, pattern.len())
679}
680
681fn package_import_source_subpath(
682 manifest: &PackageManifestInfo,
683 specifier: &str,
684) -> Option<PathBuf> {
685 let stripped = specifier.strip_prefix('#')?;
686 let without_package_name = manifest
687 .name
688 .as_deref()
689 .and_then(|name| stripped.strip_prefix(name))
690 .and_then(|rest| rest.strip_prefix('/'))
691 .unwrap_or(stripped);
692 if without_package_name.is_empty() {
693 None
694 } else {
695 Some(PathBuf::from(without_package_name))
696 }
697}
698
699fn nearest_package_manifest<'a>(
700 manifests: &'a [PackageManifestInfo],
701 from_file: &Path,
702) -> Option<&'a PackageManifestInfo> {
703 manifests
704 .iter()
705 .filter(|manifest| {
706 from_file.starts_with(&manifest.root) || from_file.starts_with(&manifest.canonical_root)
707 })
708 .max_by_key(|manifest| manifest.root.components().count())
709}
710
711fn find_package_manifest<'a>(
712 manifests: &'a [PackageManifestInfo],
713 package_name: &str,
714) -> Option<&'a PackageManifestInfo> {
715 manifests
716 .iter()
717 .find(|manifest| manifest.name.as_deref() == Some(package_name))
718}
719
720fn resolve_package_map_target(
721 ctx: &ResolveContext<'_>,
722 manifest: &PackageManifestInfo,
723 target: &str,
724 source_subpath: Option<&Path>,
725) -> Option<FileId> {
726 let target = target.strip_prefix("./")?;
727 if target.starts_with("../") || target.starts_with('/') {
728 return None;
729 }
730 let target_path = manifest.root.join(target);
731
732 lookup_internal_file_id(ctx, &target_path)
733 .or_else(|| try_source_fallback(&target_path, ctx.raw_path_to_id))
734 .or_else(|| try_source_fallback(&target_path, ctx.path_to_id))
735 .or_else(|| source_subpath.and_then(|subpath| try_source_subpath(ctx, manifest, subpath)))
736}
737
738fn resolve_package_map_targets(
739 ctx: &ResolveContext<'_>,
740 manifest: &PackageManifestInfo,
741 targets: &[String],
742 source_subpath: Option<&Path>,
743) -> Option<FileId> {
744 targets
745 .iter()
746 .find_map(|target| resolve_package_map_target(ctx, manifest, target, source_subpath))
747}
748
749fn resolve_package_import_targets(
750 ctx: &ResolveContext<'_>,
751 manifest: &PackageManifestInfo,
752 targets: &[String],
753 source_subpath: Option<&Path>,
754) -> Option<PackageImportTarget> {
755 targets.iter().find_map(|target| {
756 resolve_package_map_target(ctx, manifest, target, source_subpath)
757 .map(PackageImportTarget::Internal)
758 .or_else(|| {
759 package_import_external_target(target).map(PackageImportTarget::ExternalPackage)
760 })
761 })
762}
763
764fn package_import_external_target(target: &str) -> Option<String> {
765 if is_bare_specifier(target) && is_valid_package_name(target) {
766 Some(extract_package_name(target))
767 } else {
768 None
769 }
770}
771
772fn try_source_subpath(
773 ctx: &ResolveContext<'_>,
774 manifest: &PackageManifestInfo,
775 subpath: &Path,
776) -> Option<FileId> {
777 if subpath.as_os_str().is_empty()
778 && let Some(source) = manifest.package_json.source.as_deref()
779 && let Some(source) = source.strip_prefix("./")
780 && let Some(file_id) = lookup_internal_file_id(ctx, &manifest.root.join(source))
781 {
782 return Some(file_id);
783 }
784
785 for ext in SOURCE_EXTS {
786 let direct = if subpath.as_os_str().is_empty() {
787 manifest.root.join("src").join(format!("index.{ext}"))
788 } else {
789 manifest.root.join("src").join(subpath).with_extension(ext)
790 };
791 if let Some(file_id) = lookup_internal_file_id(ctx, &direct) {
792 return Some(file_id);
793 }
794
795 if !subpath.as_os_str().is_empty() {
796 let index = manifest
797 .root
798 .join("src")
799 .join(subpath)
800 .join(format!("index.{ext}"));
801 if let Some(file_id) = lookup_internal_file_id(ctx, &index) {
802 return Some(file_id);
803 }
804 }
805
806 if subpath.as_os_str().is_empty() {
807 let root_index = manifest.root.join(format!("index.{ext}"));
808 if let Some(file_id) = lookup_internal_file_id(ctx, &root_index) {
809 return Some(file_id);
810 }
811 }
812 }
813
814 None
815}
816
817fn lookup_internal_file_id(ctx: &ResolveContext<'_>, candidate: &Path) -> Option<FileId> {
818 if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
819 return Some(file_id);
820 }
821 if let Some(&file_id) = ctx.path_to_id.get(candidate) {
822 return Some(file_id);
823 }
824 #[cfg(not(miri))]
825 if let Ok(canonical) = dunce::canonicalize(candidate) {
826 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
827 return Some(file_id);
828 }
829 if let Some(fallback) = ctx.canonical_fallback
830 && let Some(file_id) = fallback.get(&canonical)
831 {
832 return Some(file_id);
833 }
834 }
835 None
836}
837
838pub fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
844 let components: Vec<&str> = path
845 .components()
846 .filter_map(|c| match c {
847 std::path::Component::Normal(s) => s.to_str(),
848 _ => None,
849 })
850 .collect();
851
852 let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
854
855 let after = &components[nm_idx + 1..];
856 if after.is_empty() {
857 return None;
858 }
859
860 if after[0].starts_with('@') {
861 if after.len() >= 2 {
863 Some(format!("{}/{}", after[0], after[1]))
864 } else {
865 Some(after[0].to_string())
866 }
867 } else {
868 Some(after[0].to_string())
869 }
870}
871
872pub(super) fn try_pnpm_workspace_fallback(
881 path: &Path,
882 path_to_id: &FxHashMap<&Path, FileId>,
883 workspace_roots: &FxHashMap<&str, &Path>,
884) -> Option<FileId> {
885 let components: Vec<&str> = path
887 .components()
888 .filter_map(|c| match c {
889 std::path::Component::Normal(s) => s.to_str(),
890 _ => None,
891 })
892 .collect();
893
894 let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
896
897 let after_pnpm = &components[pnpm_idx + 1..];
900
901 let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
903 let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
904
905 if after_inner_nm.is_empty() {
906 return None;
907 }
908
909 let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
911 if after_inner_nm.len() >= 2 {
912 (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
913 } else {
914 return None;
915 }
916 } else {
917 (after_inner_nm[0].to_string(), 1)
918 };
919
920 let ws_root = workspace_roots.get(pkg_name.as_str())?;
922
923 let relative_parts = &after_inner_nm[pkg_name_components..];
925 if relative_parts.is_empty() {
926 return None;
927 }
928
929 let relative_path: PathBuf = relative_parts.iter().collect();
930
931 let direct = ws_root.join(&relative_path);
933 if let Some(&file_id) = path_to_id.get(direct.as_path()) {
934 return Some(file_id);
935 }
936
937 try_source_fallback(&direct, path_to_id)
939}
940
941pub(super) fn try_workspace_package_fallback(
963 ctx: &ResolveContext<'_>,
964 specifier: &str,
965) -> Option<ResolveResult> {
966 if !super::path_info::is_bare_specifier(specifier) {
968 return None;
969 }
970 let pkg_name = super::path_info::extract_package_name(specifier);
971
972 let subpath = specifier
975 .strip_prefix(pkg_name.as_str())
976 .and_then(|s| s.strip_prefix('/'))
977 .unwrap_or("");
978 let source_subpath = PathBuf::from(subpath);
979
980 if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
981 let export_key = if subpath.is_empty() {
982 ".".to_string()
983 } else {
984 format!("./{subpath}")
985 };
986 if let Some(exports) = manifest.package_json.exports.as_ref() {
987 match package_map_target(exports, &export_key, ctx.condition_names) {
988 PackageMapTarget::Targets(targets) => {
989 if let Some(file_id) = resolve_package_map_targets(
990 ctx,
991 manifest,
992 &targets,
993 Some(source_subpath.as_path()),
994 ) {
995 return Some(ResolveResult::InternalPackageModule {
996 file_id,
997 package_name: pkg_name,
998 });
999 }
1000 }
1001 PackageMapTarget::NoMatch | PackageMapTarget::Blocked => return None,
1002 }
1003 }
1004 }
1005
1006 let (ws_root, package_name) =
1007 if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
1008 (manifest.root.as_path(), pkg_name)
1009 } else {
1010 (*ctx.workspace_roots.get(pkg_name.as_str())?, pkg_name)
1011 };
1012
1013 let root_file = ws_root.join("__fallow_ws_self_resolve__");
1016 let rel_spec = if subpath.is_empty() {
1017 "./".to_string()
1018 } else {
1019 format!("./{subpath}")
1020 };
1021
1022 let resolved = ctx.resolver.resolve_file(&root_file, &rel_spec).ok()?;
1023 let resolved_path = resolved.path();
1024
1025 if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
1026 return Some(ResolveResult::InternalPackageModule {
1027 file_id,
1028 package_name,
1029 });
1030 }
1031 if let Ok(canonical) = dunce::canonicalize(resolved_path) {
1032 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
1033 return Some(ResolveResult::InternalPackageModule {
1034 file_id,
1035 package_name,
1036 });
1037 }
1038 if let Some(fallback) = ctx.canonical_fallback
1039 && let Some(file_id) = fallback.get(&canonical)
1040 {
1041 return Some(ResolveResult::InternalPackageModule {
1042 file_id,
1043 package_name,
1044 });
1045 }
1046 if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
1047 return Some(ResolveResult::InternalPackageModule {
1048 file_id,
1049 package_name,
1050 });
1051 }
1052 }
1053 None
1054}
1055
1056pub(super) fn make_glob_from_pattern(
1058 pattern: &fallow_types::extract::DynamicImportPattern,
1059) -> String {
1060 if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
1062 return pattern.prefix.clone();
1063 }
1064 pattern.suffix.as_ref().map_or_else(
1065 || format!("{}*", pattern.prefix),
1066 |suffix| format!("{}*{}", pattern.prefix, suffix),
1067 )
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073 use rustc_hash::FxHashSet;
1074
1075 fn with_package_map_ctx(
1076 root: PathBuf,
1077 name: Option<&str>,
1078 package_json: fallow_config::PackageJson,
1079 raw_files: &[(PathBuf, FileId)],
1080 f: impl FnOnce(&ResolveContext<'_>, &PackageManifestInfo, &Path),
1081 ) {
1082 let manifest = PackageManifestInfo {
1083 root: root.clone(),
1084 canonical_root: root,
1085 name: name.map(str::to_string),
1086 package_json,
1087 };
1088 let manifests = [manifest];
1089 let mut raw_path_to_id = FxHashMap::default();
1090 for (path, file_id) in raw_files {
1091 raw_path_to_id.insert(path.as_path(), *file_id);
1092 }
1093 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1094 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1095 let condition_names = conditions();
1096 let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
1097 let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
1098 let ctx = ResolveContext {
1099 resolver: &resolver,
1100 style_resolver: &resolver,
1101 extensions: &[],
1102 path_to_id: &path_to_id,
1103 raw_path_to_id: &raw_path_to_id,
1104 workspace_roots: &workspace_roots,
1105 package_manifests: &manifests,
1106 condition_names: &condition_names,
1107 path_aliases: &[],
1108 scss_include_paths: &[],
1109 root: &manifests[0].root,
1110 canonical_fallback: None,
1111 tsconfig_warned: &tsconfig_warned,
1112 };
1113
1114 f(&ctx, &manifests[0], &manifests[0].root);
1115 }
1116
1117 #[test]
1118 fn alias_match_remainder_exact_key() {
1119 assert_eq!(alias_match_remainder("vscode", "vscode"), Some(""));
1121 assert_eq!(alias_match_remainder("@scope/sdk", "@scope/sdk"), Some(""));
1122 }
1123
1124 #[test]
1125 fn alias_match_remainder_slash_continuation() {
1126 assert_eq!(
1128 alias_match_remainder("@scope/sdk/sub", "@scope/sdk"),
1129 Some("/sub")
1130 );
1131 assert_eq!(alias_match_remainder("@/foo", "@/"), Some("foo"));
1132 assert_eq!(
1133 alias_match_remainder("~/components/x", "~/"),
1134 Some("components/x")
1135 );
1136 assert_eq!(alias_match_remainder("$lib/util", "$lib/"), Some("util"));
1137 }
1138
1139 #[test]
1140 fn alias_match_remainder_rejects_prefix_collision() {
1141 assert_eq!(
1144 alias_match_remainder("@scope/sdk-extra", "@scope/sdk"),
1145 None
1146 );
1147 assert_eq!(
1148 alias_match_remainder("vscode-languageserver", "vscode"),
1149 None
1150 );
1151 assert_eq!(alias_match_remainder("#shared-utils", "#shared"), None);
1152 }
1153
1154 #[test]
1155 fn alias_match_remainder_non_match() {
1156 assert_eq!(alias_match_remainder("react", "vscode"), None);
1157 }
1158
1159 #[test]
1160 fn test_extract_package_name_from_node_modules_path_regular() {
1161 let path = PathBuf::from("/project/node_modules/react/index.js");
1162 assert_eq!(
1163 extract_package_name_from_node_modules_path(&path),
1164 Some("react".to_string())
1165 );
1166 }
1167
1168 #[test]
1169 fn test_extract_package_name_from_node_modules_path_scoped() {
1170 let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
1171 assert_eq!(
1172 extract_package_name_from_node_modules_path(&path),
1173 Some("@babel/core".to_string())
1174 );
1175 }
1176
1177 #[test]
1178 fn test_extract_package_name_from_node_modules_path_nested() {
1179 let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
1181 assert_eq!(
1182 extract_package_name_from_node_modules_path(&path),
1183 Some("pkg-b".to_string())
1184 );
1185 }
1186
1187 #[test]
1188 fn test_extract_package_name_from_node_modules_path_deep_subpath() {
1189 let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
1190 assert_eq!(
1191 extract_package_name_from_node_modules_path(&path),
1192 Some("react-dom".to_string())
1193 );
1194 }
1195
1196 #[test]
1197 fn test_extract_package_name_from_node_modules_path_no_node_modules() {
1198 let path = PathBuf::from("/project/src/components/Button.tsx");
1199 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1200 }
1201
1202 #[test]
1203 fn test_extract_package_name_from_node_modules_path_just_node_modules() {
1204 let path = PathBuf::from("/project/node_modules");
1205 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1206 }
1207
1208 #[test]
1209 fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
1210 let path = PathBuf::from("/project/node_modules/@scope");
1212 assert_eq!(
1213 extract_package_name_from_node_modules_path(&path),
1214 Some("@scope".to_string())
1215 );
1216 }
1217
1218 #[test]
1219 fn test_resolve_specifier_node_modules_returns_npm_package() {
1220 let path =
1226 PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
1227 assert_eq!(
1228 extract_package_name_from_node_modules_path(&path),
1229 Some("styled-components".to_string())
1230 );
1231
1232 let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
1233 assert_eq!(
1234 extract_package_name_from_node_modules_path(&path),
1235 Some("next".to_string())
1236 );
1237 }
1238
1239 #[test]
1240 fn test_try_source_fallback_dist_to_src() {
1241 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1242 let mut path_to_id = FxHashMap::default();
1243 path_to_id.insert(src_path.as_path(), FileId(0));
1244
1245 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1246 assert_eq!(
1247 try_source_fallback(&dist_path, &path_to_id),
1248 Some(FileId(0)),
1249 "dist/utils.js should fall back to src/utils.ts"
1250 );
1251 }
1252
1253 #[test]
1254 fn test_try_source_fallback_build_to_src() {
1255 let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1256 let mut path_to_id = FxHashMap::default();
1257 path_to_id.insert(src_path.as_path(), FileId(1));
1258
1259 let build_path = PathBuf::from("/project/packages/core/build/index.js");
1260 assert_eq!(
1261 try_source_fallback(&build_path, &path_to_id),
1262 Some(FileId(1)),
1263 "build/index.js should fall back to src/index.tsx"
1264 );
1265 }
1266
1267 #[test]
1268 fn test_try_source_fallback_no_match() {
1269 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1270
1271 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1272 assert_eq!(
1273 try_source_fallback(&dist_path, &path_to_id),
1274 None,
1275 "should return None when no source file exists"
1276 );
1277 }
1278
1279 #[test]
1280 fn test_try_source_fallback_non_output_dir() {
1281 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1282 let mut path_to_id = FxHashMap::default();
1283 path_to_id.insert(src_path.as_path(), FileId(0));
1284
1285 let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1287 assert_eq!(
1288 try_source_fallback(&normal_path, &path_to_id),
1289 None,
1290 "non-output directory path should not trigger fallback"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_try_source_fallback_nested_path() {
1296 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1297 let mut path_to_id = FxHashMap::default();
1298 path_to_id.insert(src_path.as_path(), FileId(2));
1299
1300 let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1301 assert_eq!(
1302 try_source_fallback(&dist_path, &path_to_id),
1303 Some(FileId(2)),
1304 "nested dist path should fall back to nested src path"
1305 );
1306 }
1307
1308 #[test]
1309 fn test_try_source_fallback_nested_dist_esm() {
1310 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1311 let mut path_to_id = FxHashMap::default();
1312 path_to_id.insert(src_path.as_path(), FileId(0));
1313
1314 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1315 assert_eq!(
1316 try_source_fallback(&dist_path, &path_to_id),
1317 Some(FileId(0)),
1318 "dist/esm/utils.mjs should fall back to src/utils.ts"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_try_source_fallback_nested_build_cjs() {
1324 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1325 let mut path_to_id = FxHashMap::default();
1326 path_to_id.insert(src_path.as_path(), FileId(1));
1327
1328 let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1329 assert_eq!(
1330 try_source_fallback(&build_path, &path_to_id),
1331 Some(FileId(1)),
1332 "build/cjs/index.cjs should fall back to src/index.ts"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_try_source_fallback_nested_dist_esm_deep_path() {
1338 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1339 let mut path_to_id = FxHashMap::default();
1340 path_to_id.insert(src_path.as_path(), FileId(2));
1341
1342 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1343 assert_eq!(
1344 try_source_fallback(&dist_path, &path_to_id),
1345 Some(FileId(2)),
1346 "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_try_source_fallback_triple_nested_output_dirs() {
1352 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1353 let mut path_to_id = FxHashMap::default();
1354 path_to_id.insert(src_path.as_path(), FileId(0));
1355
1356 let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1357 assert_eq!(
1358 try_source_fallback(&dist_path, &path_to_id),
1359 Some(FileId(0)),
1360 "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1361 );
1362 }
1363
1364 #[test]
1365 fn test_try_source_fallback_parent_dir_named_build() {
1366 let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1367 let mut path_to_id = FxHashMap::default();
1368 path_to_id.insert(src_path.as_path(), FileId(0));
1369
1370 let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1371 assert_eq!(
1372 try_source_fallback(&dist_path, &path_to_id),
1373 Some(FileId(0)),
1374 "should resolve dist/ within project, not match parent 'build' dir"
1375 );
1376 }
1377
1378 #[test]
1379 fn package_map_exact_entry_beats_pattern_entry() {
1380 let map = serde_json::json!({
1381 "#nitro/runtime/task": "./dist/special/task.mjs",
1382 "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1383 });
1384 assert_eq!(
1385 package_map_target(&map, "#nitro/runtime/task", &conditions()),
1386 PackageMapTarget::Targets(vec!["./dist/special/task.mjs".to_string()])
1387 );
1388 }
1389
1390 #[test]
1391 fn package_map_wildcard_substitutes_capture() {
1392 let map = serde_json::json!({
1393 "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1394 });
1395 assert_eq!(
1396 package_map_target(&map, "#nitro/runtime/task", &conditions()),
1397 PackageMapTarget::Targets(vec!["./dist/runtime/internal/task.mjs".to_string()])
1398 );
1399 }
1400
1401 #[test]
1402 fn package_map_exact_entry_with_no_target_blocks_pattern_entry() {
1403 let map = serde_json::json!({
1404 "#nitro/runtime/task": null,
1405 "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1406 });
1407 assert_eq!(
1408 package_map_target(&map, "#nitro/runtime/task", &conditions()),
1409 PackageMapTarget::Blocked
1410 );
1411 }
1412
1413 #[test]
1414 fn package_map_best_pattern_with_no_target_blocks_broader_pattern() {
1415 let map = serde_json::json!({
1416 "#nitro/runtime/internal/*": null,
1417 "#nitro/runtime/*": "./dist/runtime/*.mjs"
1418 });
1419 assert_eq!(
1420 package_map_target(&map, "#nitro/runtime/internal/task", &conditions()),
1421 PackageMapTarget::Blocked
1422 );
1423 }
1424
1425 #[test]
1426 fn package_map_unmatched_subpath_is_not_a_target() {
1427 let map = serde_json::json!({
1428 "./query": "./dist/query/index.js"
1429 });
1430 assert_eq!(
1431 package_map_target(&map, "./private", &conditions()),
1432 PackageMapTarget::NoMatch
1433 );
1434 }
1435
1436 #[test]
1437 fn package_map_nested_conditions_follow_manifest_order() {
1438 let map = serde_json::json!({
1439 "./query/react": {
1440 "types": "./dist/query/react/index.d.ts",
1441 "import": {
1442 "development": "./src/query/react/index.ts",
1443 "default": "./dist/query/react/index.js"
1444 },
1445 "default": "./dist/query/react/index.cjs"
1446 }
1447 });
1448 assert_eq!(
1449 package_map_target(&map, "./query/react", &conditions()),
1450 PackageMapTarget::Targets(vec!["./dist/query/react/index.d.ts".to_string()])
1451 );
1452 }
1453
1454 #[test]
1455 fn package_map_import_before_types_selects_runtime_branch() {
1456 let map = serde_json::json!({
1457 ".": {
1458 "import": "./dist/index.js",
1459 "types": "./dist/index.d.ts"
1460 }
1461 });
1462 assert_eq!(
1463 package_map_target(&map, ".", &conditions()),
1464 PackageMapTarget::Targets(vec!["./dist/index.js".to_string()])
1465 );
1466 }
1467
1468 #[test]
1469 fn package_map_condition_order_follows_manifest_order() {
1470 let map = serde_json::json!({
1471 ".": {
1472 "node": "./dist/node.js",
1473 "import": "./dist/index.js"
1474 }
1475 });
1476 assert_eq!(
1477 package_map_target(&map, ".", &conditions()),
1478 PackageMapTarget::Targets(vec!["./dist/node.js".to_string()])
1479 );
1480 }
1481
1482 #[test]
1483 fn package_map_arrays_preserve_fallback_order() {
1484 let map = serde_json::json!({
1485 "#array": ["./dist/missing.js", "./src/array.ts"],
1486 "#null": null,
1487 "#false": false
1488 });
1489 assert_eq!(
1490 package_map_target(&map, "#array", &conditions()),
1491 PackageMapTarget::Targets(vec![
1492 "./dist/missing.js".to_string(),
1493 "./src/array.ts".to_string()
1494 ])
1495 );
1496 assert_eq!(
1497 package_map_target(&map, "#null", &conditions()),
1498 PackageMapTarget::Blocked
1499 );
1500 assert_eq!(
1501 package_map_target(&map, "#false", &conditions()),
1502 PackageMapTarget::Blocked
1503 );
1504 }
1505
1506 #[test]
1507 fn package_map_non_relative_target_does_not_trigger_source_fallback() {
1508 with_package_map_ctx(
1509 PathBuf::from("/project"),
1510 Some("pkg"),
1511 fallow_config::PackageJson::default(),
1512 &[],
1513 |ctx, manifest, _| {
1514 assert!(resolve_package_map_target(ctx, manifest, "lodash", None).is_none());
1515 assert!(
1516 resolve_package_map_target(ctx, manifest, "../dist/index.js", None).is_none()
1517 );
1518 },
1519 );
1520 }
1521
1522 #[test]
1523 fn package_map_targets_use_first_reachable_target() {
1524 let root = PathBuf::from("/project");
1525 let src_path = root.join("src/feature.ts");
1526 let targets = vec![
1527 "./dist/missing.js".to_string(),
1528 "./src/feature.ts".to_string(),
1529 ];
1530
1531 with_package_map_ctx(
1532 root,
1533 Some("pkg"),
1534 fallow_config::PackageJson::default(),
1535 &[(src_path, FileId(9))],
1536 |ctx, manifest, _| {
1537 assert_eq!(
1538 resolve_package_map_targets(ctx, manifest, &targets, None),
1539 Some(FileId(9))
1540 );
1541 },
1542 );
1543 }
1544
1545 #[test]
1546 fn package_imports_fallback_supports_external_package_targets() {
1547 let root = PathBuf::from("/project");
1548 with_package_map_ctx(
1549 root,
1550 Some("pkg"),
1551 fallow_config::PackageJson {
1552 imports: Some(serde_json::json!({
1553 "#pad": "left-pad",
1554 "#scoped": "@scope/pkg/subpath"
1555 })),
1556 ..Default::default()
1557 },
1558 &[],
1559 |ctx, _, root| {
1560 let pad = try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#pad");
1561 assert!(matches!(pad, Some(ResolveResult::NpmPackage(pkg)) if pkg == "left-pad"));
1562
1563 let scoped =
1564 try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#scoped");
1565 assert!(
1566 matches!(scoped, Some(ResolveResult::NpmPackage(pkg)) if pkg == "@scope/pkg")
1567 );
1568 },
1569 );
1570 }
1571
1572 #[test]
1573 fn package_imports_fallback_supports_unnamed_packages() {
1574 let root = PathBuf::from("/project");
1575 let src_path = root.join("src/runtime/task.ts");
1576 with_package_map_ctx(
1577 root,
1578 None,
1579 fallow_config::PackageJson {
1580 imports: Some(serde_json::json!({
1581 "#runtime/*": "./dist/runtime/*.mjs"
1582 })),
1583 ..Default::default()
1584 },
1585 &[(src_path, FileId(7))],
1586 |ctx, _, root| {
1587 let result =
1588 try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#runtime/task");
1589 assert!(matches!(
1590 result,
1591 Some(ResolveResult::InternalModule(FileId(7)))
1592 ));
1593 },
1594 );
1595 }
1596
1597 #[test]
1598 fn test_pnpm_store_path_extract_package_name() {
1599 let path =
1601 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1602 assert_eq!(
1603 extract_package_name_from_node_modules_path(&path),
1604 Some("react".to_string())
1605 );
1606 }
1607
1608 #[test]
1609 fn test_pnpm_store_path_scoped_package() {
1610 let path = PathBuf::from(
1611 "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1612 );
1613 assert_eq!(
1614 extract_package_name_from_node_modules_path(&path),
1615 Some("@babel/core".to_string())
1616 );
1617 }
1618
1619 fn conditions() -> Vec<String> {
1620 vec![
1621 "development".to_string(),
1622 "import".to_string(),
1623 "require".to_string(),
1624 "default".to_string(),
1625 "types".to_string(),
1626 "node".to_string(),
1627 ]
1628 }
1629
1630 #[test]
1631 fn test_pnpm_store_path_with_peer_deps() {
1632 let path = PathBuf::from(
1633 "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
1634 );
1635 assert_eq!(
1636 extract_package_name_from_node_modules_path(&path),
1637 Some("webpack".to_string())
1638 );
1639 }
1640
1641 #[test]
1642 fn test_try_pnpm_workspace_fallback_dist_to_src() {
1643 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1644 let mut path_to_id = FxHashMap::default();
1645 path_to_id.insert(src_path.as_path(), FileId(0));
1646
1647 let mut workspace_roots = FxHashMap::default();
1648 let ws_root = PathBuf::from("/project/packages/ui");
1649 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1650
1651 let pnpm_path = PathBuf::from(
1653 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
1654 );
1655 assert_eq!(
1656 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1657 Some(FileId(0)),
1658 ".pnpm workspace path should fall back to src/utils.ts"
1659 );
1660 }
1661
1662 #[test]
1663 fn test_try_pnpm_workspace_fallback_direct_source() {
1664 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1665 let mut path_to_id = FxHashMap::default();
1666 path_to_id.insert(src_path.as_path(), FileId(1));
1667
1668 let mut workspace_roots = FxHashMap::default();
1669 let ws_root = PathBuf::from("/project/packages/core");
1670 workspace_roots.insert("@myorg/core", ws_root.as_path());
1671
1672 let pnpm_path = PathBuf::from(
1674 "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
1675 );
1676 assert_eq!(
1677 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1678 Some(FileId(1)),
1679 ".pnpm workspace path with src/ should resolve directly"
1680 );
1681 }
1682
1683 #[test]
1684 fn test_try_pnpm_workspace_fallback_non_workspace_package() {
1685 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1686
1687 let mut workspace_roots = FxHashMap::default();
1688 let ws_root = PathBuf::from("/project/packages/ui");
1689 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1690
1691 let pnpm_path =
1693 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1694 assert_eq!(
1695 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1696 None,
1697 "non-workspace package in .pnpm should return None"
1698 );
1699 }
1700
1701 #[test]
1702 fn test_try_pnpm_workspace_fallback_unscoped_package() {
1703 let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
1704 let mut path_to_id = FxHashMap::default();
1705 path_to_id.insert(src_path.as_path(), FileId(2));
1706
1707 let mut workspace_roots = FxHashMap::default();
1708 let ws_root = PathBuf::from("/project/packages/utils");
1709 workspace_roots.insert("my-utils", ws_root.as_path());
1710
1711 let pnpm_path = PathBuf::from(
1713 "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
1714 );
1715 assert_eq!(
1716 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1717 Some(FileId(2)),
1718 "unscoped workspace package in .pnpm should resolve"
1719 );
1720 }
1721
1722 #[test]
1723 fn test_try_pnpm_workspace_fallback_nested_path() {
1724 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1725 let mut path_to_id = FxHashMap::default();
1726 path_to_id.insert(src_path.as_path(), FileId(3));
1727
1728 let mut workspace_roots = FxHashMap::default();
1729 let ws_root = PathBuf::from("/project/packages/ui");
1730 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1731
1732 let pnpm_path = PathBuf::from(
1734 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1735 );
1736 assert_eq!(
1737 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1738 Some(FileId(3)),
1739 "nested .pnpm workspace path should resolve through source fallback"
1740 );
1741 }
1742
1743 #[test]
1744 fn test_try_pnpm_workspace_fallback_no_pnpm() {
1745 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1746 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1747
1748 let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1750 assert_eq!(
1751 try_pnpm_workspace_fallback(®ular_path, &path_to_id, &workspace_roots),
1752 None,
1753 );
1754 }
1755
1756 #[test]
1757 fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1758 let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1759 let mut path_to_id = FxHashMap::default();
1760 path_to_id.insert(src_path.as_path(), FileId(4));
1761
1762 let mut workspace_roots = FxHashMap::default();
1763 let ws_root = PathBuf::from("/project/packages/ui");
1764 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1765
1766 let pnpm_path = PathBuf::from(
1768 "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1769 );
1770 assert_eq!(
1771 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1772 Some(FileId(4)),
1773 ".pnpm path with peer dep suffix should still resolve"
1774 );
1775 }
1776
1777 #[test]
1780 fn make_glob_prefix_only_no_suffix() {
1781 let pattern = fallow_types::extract::DynamicImportPattern {
1782 prefix: "./locales/".to_string(),
1783 suffix: None,
1784 span: oxc_span::Span::default(),
1785 };
1786 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*");
1787 }
1788
1789 #[test]
1790 fn make_glob_prefix_with_suffix() {
1791 let pattern = fallow_types::extract::DynamicImportPattern {
1792 prefix: "./locales/".to_string(),
1793 suffix: Some(".json".to_string()),
1794 span: oxc_span::Span::default(),
1795 };
1796 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1797 }
1798
1799 #[test]
1800 fn make_glob_passthrough_star() {
1801 let pattern = fallow_types::extract::DynamicImportPattern {
1803 prefix: "./pages/**/*.tsx".to_string(),
1804 suffix: None,
1805 span: oxc_span::Span::default(),
1806 };
1807 assert_eq!(make_glob_from_pattern(&pattern), "./pages/**/*.tsx");
1808 }
1809
1810 #[test]
1811 fn make_glob_passthrough_brace() {
1812 let pattern = fallow_types::extract::DynamicImportPattern {
1813 prefix: "./i18n/{en,de,fr}.json".to_string(),
1814 suffix: None,
1815 span: oxc_span::Span::default(),
1816 };
1817 assert_eq!(make_glob_from_pattern(&pattern), "./i18n/{en,de,fr}.json");
1818 }
1819
1820 #[test]
1821 fn make_glob_empty_prefix_no_suffix() {
1822 let pattern = fallow_types::extract::DynamicImportPattern {
1823 prefix: String::new(),
1824 suffix: None,
1825 span: oxc_span::Span::default(),
1826 };
1827 assert_eq!(make_glob_from_pattern(&pattern), "*");
1828 }
1829
1830 #[test]
1831 fn make_glob_empty_prefix_with_suffix() {
1832 let pattern = fallow_types::extract::DynamicImportPattern {
1833 prefix: String::new(),
1834 suffix: Some(".ts".to_string()),
1835 span: oxc_span::Span::default(),
1836 };
1837 assert_eq!(make_glob_from_pattern(&pattern), "*.ts");
1838 }
1839
1840 #[test]
1843 fn make_glob_template_literal_prefix_only() {
1844 let pattern = fallow_types::extract::DynamicImportPattern {
1846 prefix: "./pages/".to_string(),
1847 suffix: None,
1848 span: oxc_span::Span::default(),
1849 };
1850 assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1851 }
1852
1853 #[test]
1854 fn make_glob_template_literal_with_extension_suffix() {
1855 let pattern = fallow_types::extract::DynamicImportPattern {
1857 prefix: "./locales/".to_string(),
1858 suffix: Some(".json".to_string()),
1859 span: oxc_span::Span::default(),
1860 };
1861 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1862 }
1863
1864 #[test]
1865 fn make_glob_template_literal_deep_prefix() {
1866 let pattern = fallow_types::extract::DynamicImportPattern {
1869 prefix: "./modules/".to_string(),
1870 suffix: None,
1871 span: oxc_span::Span::default(),
1872 };
1873 assert_eq!(make_glob_from_pattern(&pattern), "./modules/*");
1874 }
1875
1876 #[test]
1877 fn make_glob_string_concat_prefix() {
1878 let pattern = fallow_types::extract::DynamicImportPattern {
1880 prefix: "./pages/".to_string(),
1881 suffix: None,
1882 span: oxc_span::Span::default(),
1883 };
1884 assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1885 }
1886
1887 #[test]
1888 fn make_glob_string_concat_with_extension() {
1889 let pattern = fallow_types::extract::DynamicImportPattern {
1891 prefix: "./views/".to_string(),
1892 suffix: Some(".vue".to_string()),
1893 span: oxc_span::Span::default(),
1894 };
1895 assert_eq!(make_glob_from_pattern(&pattern), "./views/*.vue");
1896 }
1897
1898 #[test]
1901 fn make_glob_import_meta_glob_recursive() {
1902 let pattern = fallow_types::extract::DynamicImportPattern {
1904 prefix: "./components/**/*.vue".to_string(),
1905 suffix: None,
1906 span: oxc_span::Span::default(),
1907 };
1908 assert_eq!(
1909 make_glob_from_pattern(&pattern),
1910 "./components/**/*.vue",
1911 "import.meta.glob patterns with * should pass through as-is"
1912 );
1913 }
1914
1915 #[test]
1916 fn make_glob_import_meta_glob_brace_expansion() {
1917 let pattern = fallow_types::extract::DynamicImportPattern {
1919 prefix: "./plugins/{auth,analytics}.ts".to_string(),
1920 suffix: None,
1921 span: oxc_span::Span::default(),
1922 };
1923 assert_eq!(
1924 make_glob_from_pattern(&pattern),
1925 "./plugins/{auth,analytics}.ts",
1926 "import.meta.glob patterns with braces should pass through as-is"
1927 );
1928 }
1929
1930 #[test]
1931 fn make_glob_import_meta_glob_star_with_brace() {
1932 let pattern = fallow_types::extract::DynamicImportPattern {
1934 prefix: "./routes/**/*.{ts,tsx}".to_string(),
1935 suffix: None,
1936 span: oxc_span::Span::default(),
1937 };
1938 assert_eq!(
1939 make_glob_from_pattern(&pattern),
1940 "./routes/**/*.{ts,tsx}",
1941 "combined * and brace patterns should pass through"
1942 );
1943 }
1944
1945 #[test]
1946 fn make_glob_import_meta_glob_ignores_suffix_when_star_present() {
1947 let pattern = fallow_types::extract::DynamicImportPattern {
1949 prefix: "./*.ts".to_string(),
1950 suffix: Some(".extra".to_string()),
1951 span: oxc_span::Span::default(),
1952 };
1953 assert_eq!(
1954 make_glob_from_pattern(&pattern),
1955 "./*.ts",
1956 "when prefix has glob chars, suffix is ignored (prefix used as-is)"
1957 );
1958 }
1959
1960 #[test]
1963 fn make_glob_single_dot_prefix() {
1964 let pattern = fallow_types::extract::DynamicImportPattern {
1965 prefix: "./".to_string(),
1966 suffix: None,
1967 span: oxc_span::Span::default(),
1968 };
1969 assert_eq!(make_glob_from_pattern(&pattern), "./*");
1970 }
1971
1972 #[test]
1973 fn make_glob_prefix_without_trailing_slash() {
1974 let pattern = fallow_types::extract::DynamicImportPattern {
1976 prefix: "./config".to_string(),
1977 suffix: None,
1978 span: oxc_span::Span::default(),
1979 };
1980 assert_eq!(make_glob_from_pattern(&pattern), "./config*");
1981 }
1982
1983 #[test]
1984 fn make_glob_prefix_with_dotdot() {
1985 let pattern = fallow_types::extract::DynamicImportPattern {
1986 prefix: "../shared/".to_string(),
1987 suffix: Some(".ts".to_string()),
1988 span: oxc_span::Span::default(),
1989 };
1990 assert_eq!(make_glob_from_pattern(&pattern), "../shared/*.ts");
1991 }
1992
1993 #[test]
1996 fn test_extract_package_name_with_pnpm_plus_encoded_scope() {
1997 let path = PathBuf::from(
2000 "/project/node_modules/.pnpm/@mui+material@5.15.0/node_modules/@mui/material/index.js",
2001 );
2002 assert_eq!(
2003 extract_package_name_from_node_modules_path(&path),
2004 Some("@mui/material".to_string())
2005 );
2006 }
2007
2008 #[test]
2009 fn test_extract_package_name_windows_style_path() {
2010 let path = PathBuf::from("/project/node_modules/typescript/lib/tsc.js");
2012 assert_eq!(
2013 extract_package_name_from_node_modules_path(&path),
2014 Some("typescript".to_string())
2015 );
2016 }
2017
2018 #[test]
2021 fn test_try_source_fallback_out_dir() {
2022 let src_path = PathBuf::from("/project/packages/api/src/handler.ts");
2023 let mut path_to_id = FxHashMap::default();
2024 path_to_id.insert(src_path.as_path(), FileId(5));
2025
2026 let out_path = PathBuf::from("/project/packages/api/out/handler.js");
2027 assert_eq!(
2028 try_source_fallback(&out_path, &path_to_id),
2029 Some(FileId(5)),
2030 "out/handler.js should fall back to src/handler.ts"
2031 );
2032 }
2033
2034 #[test]
2035 fn test_try_source_fallback_mts_extension() {
2036 let src_path = PathBuf::from("/project/packages/lib/src/utils.mts");
2037 let mut path_to_id = FxHashMap::default();
2038 path_to_id.insert(src_path.as_path(), FileId(6));
2039
2040 let dist_path = PathBuf::from("/project/packages/lib/dist/utils.mjs");
2041 assert_eq!(
2042 try_source_fallback(&dist_path, &path_to_id),
2043 Some(FileId(6)),
2044 "dist/utils.mjs should fall back to src/utils.mts"
2045 );
2046 }
2047
2048 #[test]
2049 fn test_try_source_fallback_cts_extension() {
2050 let src_path = PathBuf::from("/project/packages/lib/src/config.cts");
2051 let mut path_to_id = FxHashMap::default();
2052 path_to_id.insert(src_path.as_path(), FileId(7));
2053
2054 let dist_path = PathBuf::from("/project/packages/lib/dist/config.cjs");
2055 assert_eq!(
2056 try_source_fallback(&dist_path, &path_to_id),
2057 Some(FileId(7)),
2058 "dist/config.cjs should fall back to src/config.cts"
2059 );
2060 }
2061
2062 #[test]
2063 fn test_try_source_fallback_jsx_extension() {
2064 let src_path = PathBuf::from("/project/packages/ui/src/App.jsx");
2065 let mut path_to_id = FxHashMap::default();
2066 path_to_id.insert(src_path.as_path(), FileId(8));
2067
2068 let build_path = PathBuf::from("/project/packages/ui/build/App.js");
2069 assert_eq!(
2070 try_source_fallback(&build_path, &path_to_id),
2071 Some(FileId(8)),
2072 "build/App.js should fall back to src/App.jsx"
2073 );
2074 }
2075
2076 #[test]
2077 fn test_try_source_fallback_no_file_stem() {
2078 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2080 let dist_path = PathBuf::from("/project/packages/ui/dist/");
2081 assert_eq!(
2082 try_source_fallback(&dist_path, &path_to_id),
2083 None,
2084 "directory path with no file should return None"
2085 );
2086 }
2087
2088 #[test]
2089 fn test_try_source_fallback_esm_subdir() {
2090 let src_path = PathBuf::from("/project/lib/src/index.ts");
2092 let mut path_to_id = FxHashMap::default();
2093 path_to_id.insert(src_path.as_path(), FileId(10));
2094
2095 let dist_path = PathBuf::from("/project/lib/esm/index.mjs");
2096 assert_eq!(
2097 try_source_fallback(&dist_path, &path_to_id),
2098 Some(FileId(10)),
2099 "standalone esm/ directory should fall back to src/"
2100 );
2101 }
2102
2103 #[test]
2104 fn test_try_source_fallback_cjs_subdir() {
2105 let src_path = PathBuf::from("/project/lib/src/index.ts");
2106 let mut path_to_id = FxHashMap::default();
2107 path_to_id.insert(src_path.as_path(), FileId(11));
2108
2109 let cjs_path = PathBuf::from("/project/lib/cjs/index.cjs");
2110 assert_eq!(
2111 try_source_fallback(&cjs_path, &path_to_id),
2112 Some(FileId(11)),
2113 "standalone cjs/ directory should fall back to src/"
2114 );
2115 }
2116
2117 #[test]
2120 fn test_try_pnpm_workspace_fallback_empty_after_pnpm() {
2121 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2123 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2124
2125 let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/node_modules");
2126 assert_eq!(
2127 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2128 None,
2129 "path ending at node_modules with nothing after should return None"
2130 );
2131 }
2132
2133 #[test]
2134 fn test_try_pnpm_workspace_fallback_scoped_package_only_scope() {
2135 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2137 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2138
2139 let pnpm_path =
2140 PathBuf::from("/project/node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope");
2141 assert_eq!(
2142 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2143 None,
2144 "scoped package without full name and no matching workspace should return None"
2145 );
2146 }
2147
2148 #[test]
2149 fn test_try_pnpm_workspace_fallback_no_inner_node_modules() {
2150 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2152 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2153
2154 let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/dist/index.js");
2155 assert_eq!(
2156 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2157 None,
2158 "path without inner node_modules after .pnpm should return None"
2159 );
2160 }
2161
2162 #[test]
2163 fn test_try_pnpm_workspace_fallback_package_without_relative_path() {
2164 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2166 let mut workspace_roots = FxHashMap::default();
2167 let ws_root = PathBuf::from("/project/packages/ui");
2168 workspace_roots.insert("@myorg/ui", ws_root.as_path());
2169
2170 let pnpm_path =
2171 PathBuf::from("/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui");
2172 assert_eq!(
2173 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2174 None,
2175 "path ending at package name with no relative file should return None"
2176 );
2177 }
2178
2179 #[test]
2180 fn test_try_pnpm_workspace_fallback_nested_dist_esm() {
2181 let src_path = PathBuf::from("/project/packages/ui/src/Button.ts");
2182 let mut path_to_id = FxHashMap::default();
2183 path_to_id.insert(src_path.as_path(), FileId(10));
2184
2185 let mut workspace_roots = FxHashMap::default();
2186 let ws_root = PathBuf::from("/project/packages/ui");
2187 workspace_roots.insert("@myorg/ui", ws_root.as_path());
2188
2189 let pnpm_path = PathBuf::from(
2191 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/esm/Button.mjs",
2192 );
2193 assert_eq!(
2194 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2195 Some(FileId(10)),
2196 "pnpm path with nested dist/esm should resolve through source fallback"
2197 );
2198 }
2199}