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