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