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
505#[derive(Debug, Clone, PartialEq, Eq)]
506enum PackageMapTarget {
507 NoMatch,
508 Blocked,
509 Targets(Vec<String>),
510}
511
512enum PackageImportTarget {
513 Internal(FileId),
514 ExternalPackage(String),
515}
516
517fn package_map_match_value(
518 value: &Value,
519 condition_names: &[String],
520 capture: Option<&str>,
521) -> PackageMapTarget {
522 resolve_package_map_value(value, condition_names, capture)
523 .filter(|targets| !targets.is_empty())
524 .map_or(PackageMapTarget::Blocked, PackageMapTarget::Targets)
525}
526
527fn package_map_target(
528 map: &Value,
529 specifier_key: &str,
530 condition_names: &[String],
531) -> PackageMapTarget {
532 let Some(obj) = map.as_object() else {
533 if specifier_key == "." {
534 return package_map_match_value(map, condition_names, None);
535 }
536 return PackageMapTarget::NoMatch;
537 };
538
539 let has_subpath_keys = obj
540 .keys()
541 .any(|key| key == "." || key.starts_with("./") || key.starts_with('#'));
542 if !has_subpath_keys {
543 if specifier_key == "." {
544 return package_map_match_value(map, condition_names, None);
545 }
546 return PackageMapTarget::NoMatch;
547 }
548
549 if let Some(value) = obj.get(specifier_key) {
550 return package_map_match_value(value, condition_names, None);
551 }
552
553 let mut patterns: Vec<(&str, &Value, String)> = obj
554 .iter()
555 .filter_map(|(pattern, value)| {
556 package_map_pattern_capture(pattern, specifier_key)
557 .map(|capture| (pattern.as_str(), value, capture))
558 })
559 .collect();
560 patterns.sort_by(|(left, _, _), (right, _, _)| {
561 package_map_pattern_specificity(right).cmp(&package_map_pattern_specificity(left))
562 });
563
564 patterns
565 .first()
566 .map_or(PackageMapTarget::NoMatch, |(_, value, capture)| {
567 package_map_match_value(value, condition_names, Some(capture))
568 })
569}
570
571fn resolve_package_map_value(
572 value: &Value,
573 condition_names: &[String],
574 capture: Option<&str>,
575) -> Option<Vec<String>> {
576 match value {
577 Value::String(target) => Some(vec![match capture {
578 Some(capture) => target.replace('*', capture),
579 None => target.clone(),
580 }]),
581 Value::Object(map) => {
582 for (condition, value) in map {
583 if (condition == "default"
584 || condition_names
585 .iter()
586 .any(|active_condition| active_condition == condition))
587 && let Some(targets) =
588 resolve_package_map_value(value, condition_names, capture)
589 {
590 return Some(targets);
591 }
592 }
593 None
594 }
595 Value::Array(values) => {
596 let targets: Vec<String> = values
597 .iter()
598 .filter_map(|value| resolve_package_map_value(value, condition_names, capture))
599 .flatten()
600 .collect();
601 (!targets.is_empty()).then_some(targets)
602 }
603 Value::Bool(_) | Value::Null | Value::Number(_) => None,
604 }
605}
606
607fn package_map_pattern_capture(pattern: &str, specifier: &str) -> Option<String> {
608 let star = pattern.find('*')?;
609 if pattern[star + 1..].contains('*') {
610 return None;
611 }
612 let (prefix, suffix_with_star) = pattern.split_at(star);
613 let suffix = &suffix_with_star[1..];
614 let captured = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
615 Some(captured.to_string())
616}
617
618fn package_map_pattern_specificity(pattern: &str) -> (usize, usize) {
619 let star = pattern.find('*').unwrap_or(pattern.len());
620 (star, pattern.len())
621}
622
623fn package_import_source_subpath(
624 manifest: &PackageManifestInfo,
625 specifier: &str,
626) -> Option<PathBuf> {
627 let stripped = specifier.strip_prefix('#')?;
628 let without_package_name = manifest
629 .name
630 .as_deref()
631 .and_then(|name| stripped.strip_prefix(name))
632 .and_then(|rest| rest.strip_prefix('/'))
633 .unwrap_or(stripped);
634 if without_package_name.is_empty() {
635 None
636 } else {
637 Some(PathBuf::from(without_package_name))
638 }
639}
640
641fn nearest_package_manifest<'a>(
642 manifests: &'a [PackageManifestInfo],
643 from_file: &Path,
644) -> Option<&'a PackageManifestInfo> {
645 manifests
646 .iter()
647 .filter(|manifest| {
648 from_file.starts_with(&manifest.root) || from_file.starts_with(&manifest.canonical_root)
649 })
650 .max_by_key(|manifest| manifest.root.components().count())
651}
652
653fn find_package_manifest<'a>(
654 manifests: &'a [PackageManifestInfo],
655 package_name: &str,
656) -> Option<&'a PackageManifestInfo> {
657 manifests
658 .iter()
659 .find(|manifest| manifest.name.as_deref() == Some(package_name))
660}
661
662fn resolve_package_map_target(
663 ctx: &ResolveContext<'_>,
664 manifest: &PackageManifestInfo,
665 target: &str,
666 source_subpath: Option<&Path>,
667) -> Option<FileId> {
668 let target = target.strip_prefix("./")?;
669 if target.starts_with("../") || target.starts_with('/') {
670 return None;
671 }
672 let target_path = manifest.root.join(target);
673
674 lookup_internal_file_id(ctx, &target_path)
675 .or_else(|| try_source_fallback(&target_path, ctx.raw_path_to_id))
676 .or_else(|| try_source_fallback(&target_path, ctx.path_to_id))
677 .or_else(|| source_subpath.and_then(|subpath| try_source_subpath(ctx, manifest, subpath)))
678}
679
680fn resolve_package_map_targets(
681 ctx: &ResolveContext<'_>,
682 manifest: &PackageManifestInfo,
683 targets: &[String],
684 source_subpath: Option<&Path>,
685) -> Option<FileId> {
686 targets
687 .iter()
688 .find_map(|target| resolve_package_map_target(ctx, manifest, target, source_subpath))
689}
690
691fn resolve_package_import_targets(
692 ctx: &ResolveContext<'_>,
693 manifest: &PackageManifestInfo,
694 targets: &[String],
695 source_subpath: Option<&Path>,
696) -> Option<PackageImportTarget> {
697 targets.iter().find_map(|target| {
698 resolve_package_map_target(ctx, manifest, target, source_subpath)
699 .map(PackageImportTarget::Internal)
700 .or_else(|| {
701 package_import_external_target(target).map(PackageImportTarget::ExternalPackage)
702 })
703 })
704}
705
706fn package_import_external_target(target: &str) -> Option<String> {
707 if is_bare_specifier(target) && is_valid_package_name(target) {
708 Some(extract_package_name(target))
709 } else {
710 None
711 }
712}
713
714fn try_source_subpath(
715 ctx: &ResolveContext<'_>,
716 manifest: &PackageManifestInfo,
717 subpath: &Path,
718) -> Option<FileId> {
719 if subpath.as_os_str().is_empty()
720 && let Some(source) = manifest.package_json.source.as_deref()
721 && let Some(source) = source.strip_prefix("./")
722 && let Some(file_id) = lookup_internal_file_id(ctx, &manifest.root.join(source))
723 {
724 return Some(file_id);
725 }
726
727 for ext in SOURCE_EXTS {
728 let direct = if subpath.as_os_str().is_empty() {
729 manifest.root.join("src").join(format!("index.{ext}"))
730 } else {
731 manifest.root.join("src").join(subpath).with_extension(ext)
732 };
733 if let Some(file_id) = lookup_internal_file_id(ctx, &direct) {
734 return Some(file_id);
735 }
736
737 if !subpath.as_os_str().is_empty() {
738 let index = manifest
739 .root
740 .join("src")
741 .join(subpath)
742 .join(format!("index.{ext}"));
743 if let Some(file_id) = lookup_internal_file_id(ctx, &index) {
744 return Some(file_id);
745 }
746 }
747
748 if subpath.as_os_str().is_empty() {
749 let root_index = manifest.root.join(format!("index.{ext}"));
750 if let Some(file_id) = lookup_internal_file_id(ctx, &root_index) {
751 return Some(file_id);
752 }
753 }
754 }
755
756 None
757}
758
759fn lookup_internal_file_id(ctx: &ResolveContext<'_>, candidate: &Path) -> Option<FileId> {
760 if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
761 return Some(file_id);
762 }
763 if let Some(&file_id) = ctx.path_to_id.get(candidate) {
764 return Some(file_id);
765 }
766 #[cfg(not(miri))]
767 if let Ok(canonical) = dunce::canonicalize(candidate) {
768 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
769 return Some(file_id);
770 }
771 if let Some(fallback) = ctx.canonical_fallback
772 && let Some(file_id) = fallback.get(&canonical)
773 {
774 return Some(file_id);
775 }
776 }
777 None
778}
779
780pub fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
786 let components: Vec<&str> = path
787 .components()
788 .filter_map(|c| match c {
789 std::path::Component::Normal(s) => s.to_str(),
790 _ => None,
791 })
792 .collect();
793
794 let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
795
796 let after = &components[nm_idx + 1..];
797 if after.is_empty() {
798 return None;
799 }
800
801 if after[0].starts_with('@') {
802 if after.len() >= 2 {
803 Some(format!("{}/{}", after[0], after[1]))
804 } else {
805 Some(after[0].to_string())
806 }
807 } else {
808 Some(after[0].to_string())
809 }
810}
811
812pub(super) fn try_pnpm_workspace_fallback(
821 path: &Path,
822 path_to_id: &FxHashMap<&Path, FileId>,
823 workspace_roots: &FxHashMap<&str, &Path>,
824) -> Option<FileId> {
825 let components: Vec<&str> = path
826 .components()
827 .filter_map(|c| match c {
828 std::path::Component::Normal(s) => s.to_str(),
829 _ => None,
830 })
831 .collect();
832
833 let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
834
835 let after_pnpm = &components[pnpm_idx + 1..];
836
837 let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
838 let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
839
840 if after_inner_nm.is_empty() {
841 return None;
842 }
843
844 let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
845 if after_inner_nm.len() >= 2 {
846 (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
847 } else {
848 return None;
849 }
850 } else {
851 (after_inner_nm[0].to_string(), 1)
852 };
853
854 let ws_root = workspace_roots.get(pkg_name.as_str())?;
855
856 let relative_parts = &after_inner_nm[pkg_name_components..];
857 if relative_parts.is_empty() {
858 return None;
859 }
860
861 let relative_path: PathBuf = relative_parts.iter().collect();
862
863 let direct = ws_root.join(&relative_path);
864 if let Some(&file_id) = path_to_id.get(direct.as_path()) {
865 return Some(file_id);
866 }
867
868 try_source_fallback(&direct, path_to_id)
869}
870
871pub(super) fn try_workspace_package_fallback(
894 ctx: &ResolveContext<'_>,
895 specifier: &str,
896) -> Option<ResolveResult> {
897 if !super::path_info::is_bare_specifier(specifier) {
898 return None;
899 }
900 let pkg_name = super::path_info::extract_package_name(specifier);
901
902 let subpath = specifier
903 .strip_prefix(pkg_name.as_str())
904 .and_then(|s| s.strip_prefix('/'))
905 .unwrap_or("");
906 let source_subpath = PathBuf::from(subpath);
907
908 if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
909 let export_key = if subpath.is_empty() {
910 ".".to_string()
911 } else {
912 format!("./{subpath}")
913 };
914 if let Some(exports) = manifest.package_json.exports.as_ref() {
915 match package_map_target(exports, &export_key, ctx.condition_names) {
916 PackageMapTarget::Targets(targets) => {
917 if let Some(file_id) = resolve_package_map_targets(
918 ctx,
919 manifest,
920 &targets,
921 Some(source_subpath.as_path()),
922 ) {
923 return Some(ResolveResult::InternalPackageModule {
924 file_id,
925 package_name: pkg_name,
926 });
927 }
928 }
929 PackageMapTarget::NoMatch | PackageMapTarget::Blocked => return None,
930 }
931 } else if let Some(file_id) = try_source_subpath(ctx, manifest, source_subpath.as_path()) {
932 return Some(ResolveResult::InternalPackageModule {
933 file_id,
934 package_name: pkg_name,
935 });
936 }
937 }
938
939 let (ws_root, package_name) =
940 if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
941 (manifest.root.as_path(), pkg_name)
942 } else {
943 (*ctx.workspace_roots.get(pkg_name.as_str())?, pkg_name)
944 };
945
946 let root_file = ws_root.join("__fallow_ws_self_resolve__");
947 let rel_spec = if subpath.is_empty() {
948 "./".to_string()
949 } else {
950 format!("./{subpath}")
951 };
952
953 let resolved = ctx.resolver.resolve_file(&root_file, &rel_spec).ok()?;
954 let resolved_path = resolved.path();
955
956 if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
957 return Some(ResolveResult::InternalPackageModule {
958 file_id,
959 package_name,
960 });
961 }
962 if let Ok(canonical) = dunce::canonicalize(resolved_path) {
963 if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
964 return Some(ResolveResult::InternalPackageModule {
965 file_id,
966 package_name,
967 });
968 }
969 if let Some(fallback) = ctx.canonical_fallback
970 && let Some(file_id) = fallback.get(&canonical)
971 {
972 return Some(ResolveResult::InternalPackageModule {
973 file_id,
974 package_name,
975 });
976 }
977 if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
978 return Some(ResolveResult::InternalPackageModule {
979 file_id,
980 package_name,
981 });
982 }
983 }
984 None
985}
986
987pub(super) fn make_glob_from_pattern(
989 pattern: &fallow_types::extract::DynamicImportPattern,
990) -> String {
991 if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
992 return pattern.prefix.clone();
993 }
994 pattern.suffix.as_ref().map_or_else(
995 || format!("{}*", pattern.prefix),
996 |suffix| format!("{}*{}", pattern.prefix, suffix),
997 )
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003 use rustc_hash::FxHashSet;
1004
1005 fn with_package_map_ctx(
1006 root: PathBuf,
1007 name: Option<&str>,
1008 package_json: fallow_config::PackageJson,
1009 raw_files: &[(PathBuf, FileId)],
1010 f: impl FnOnce(&ResolveContext<'_>, &PackageManifestInfo, &Path),
1011 ) {
1012 let manifest = PackageManifestInfo {
1013 root: root.clone(),
1014 canonical_root: root,
1015 name: name.map(str::to_string),
1016 package_json,
1017 };
1018 let manifests = [manifest];
1019 let mut raw_path_to_id = FxHashMap::default();
1020 for (path, file_id) in raw_files {
1021 raw_path_to_id.insert(path.as_path(), *file_id);
1022 }
1023 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1024 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1025 let condition_names = conditions();
1026 let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
1027 let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
1028 let ctx = ResolveContext {
1029 resolver: &resolver,
1030 style_resolver: &resolver,
1031 extensions: &[],
1032 path_to_id: &path_to_id,
1033 raw_path_to_id: &raw_path_to_id,
1034 workspace_roots: &workspace_roots,
1035 package_manifests: &manifests,
1036 condition_names: &condition_names,
1037 path_aliases: &[],
1038 scss_include_paths: &[],
1039 static_dir_mappings: &[],
1040 root: &manifests[0].root,
1041 canonical_fallback: None,
1042 tsconfig_warned: &tsconfig_warned,
1043 };
1044
1045 f(&ctx, &manifests[0], &manifests[0].root);
1046 }
1047
1048 #[test]
1049 fn alias_match_remainder_exact_key() {
1050 assert_eq!(alias_match_remainder("vscode", "vscode"), Some(""));
1051 assert_eq!(alias_match_remainder("@scope/sdk", "@scope/sdk"), Some(""));
1052 }
1053
1054 #[test]
1055 fn alias_match_remainder_slash_continuation() {
1056 assert_eq!(
1057 alias_match_remainder("@scope/sdk/sub", "@scope/sdk"),
1058 Some("/sub")
1059 );
1060 assert_eq!(alias_match_remainder("@/foo", "@/"), Some("foo"));
1061 assert_eq!(
1062 alias_match_remainder("~/components/x", "~/"),
1063 Some("components/x")
1064 );
1065 assert_eq!(alias_match_remainder("$lib/util", "$lib/"), Some("util"));
1066 }
1067
1068 #[test]
1069 fn alias_match_remainder_rejects_prefix_collision() {
1070 assert_eq!(
1071 alias_match_remainder("@scope/sdk-extra", "@scope/sdk"),
1072 None
1073 );
1074 assert_eq!(
1075 alias_match_remainder("vscode-languageserver", "vscode"),
1076 None
1077 );
1078 assert_eq!(alias_match_remainder("#shared-utils", "#shared"), None);
1079 }
1080
1081 #[test]
1082 fn alias_match_remainder_non_match() {
1083 assert_eq!(alias_match_remainder("react", "vscode"), None);
1084 }
1085
1086 #[test]
1087 fn test_extract_package_name_from_node_modules_path_regular() {
1088 let path = PathBuf::from("/project/node_modules/react/index.js");
1089 assert_eq!(
1090 extract_package_name_from_node_modules_path(&path),
1091 Some("react".to_string())
1092 );
1093 }
1094
1095 #[test]
1096 fn test_extract_package_name_from_node_modules_path_scoped() {
1097 let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
1098 assert_eq!(
1099 extract_package_name_from_node_modules_path(&path),
1100 Some("@babel/core".to_string())
1101 );
1102 }
1103
1104 #[test]
1105 fn test_extract_package_name_from_node_modules_path_nested() {
1106 let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
1107 assert_eq!(
1108 extract_package_name_from_node_modules_path(&path),
1109 Some("pkg-b".to_string())
1110 );
1111 }
1112
1113 #[test]
1114 fn test_extract_package_name_from_node_modules_path_deep_subpath() {
1115 let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
1116 assert_eq!(
1117 extract_package_name_from_node_modules_path(&path),
1118 Some("react-dom".to_string())
1119 );
1120 }
1121
1122 #[test]
1123 fn test_extract_package_name_from_node_modules_path_no_node_modules() {
1124 let path = PathBuf::from("/project/src/components/Button.tsx");
1125 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1126 }
1127
1128 #[test]
1129 fn test_extract_package_name_from_node_modules_path_just_node_modules() {
1130 let path = PathBuf::from("/project/node_modules");
1131 assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1132 }
1133
1134 #[test]
1135 fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
1136 let path = PathBuf::from("/project/node_modules/@scope");
1137 assert_eq!(
1138 extract_package_name_from_node_modules_path(&path),
1139 Some("@scope".to_string())
1140 );
1141 }
1142
1143 #[test]
1144 fn test_resolve_specifier_node_modules_returns_npm_package() {
1145 let path =
1146 PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
1147 assert_eq!(
1148 extract_package_name_from_node_modules_path(&path),
1149 Some("styled-components".to_string())
1150 );
1151
1152 let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
1153 assert_eq!(
1154 extract_package_name_from_node_modules_path(&path),
1155 Some("next".to_string())
1156 );
1157 }
1158
1159 #[test]
1160 fn test_try_source_fallback_dist_to_src() {
1161 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1162 let mut path_to_id = FxHashMap::default();
1163 path_to_id.insert(src_path.as_path(), FileId(0));
1164
1165 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1166 assert_eq!(
1167 try_source_fallback(&dist_path, &path_to_id),
1168 Some(FileId(0)),
1169 "dist/utils.js should fall back to src/utils.ts"
1170 );
1171 }
1172
1173 #[test]
1174 fn test_try_source_fallback_build_to_src() {
1175 let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1176 let mut path_to_id = FxHashMap::default();
1177 path_to_id.insert(src_path.as_path(), FileId(1));
1178
1179 let build_path = PathBuf::from("/project/packages/core/build/index.js");
1180 assert_eq!(
1181 try_source_fallback(&build_path, &path_to_id),
1182 Some(FileId(1)),
1183 "build/index.js should fall back to src/index.tsx"
1184 );
1185 }
1186
1187 #[test]
1188 fn test_try_source_fallback_no_match() {
1189 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1190
1191 let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1192 assert_eq!(
1193 try_source_fallback(&dist_path, &path_to_id),
1194 None,
1195 "should return None when no source file exists"
1196 );
1197 }
1198
1199 #[test]
1200 fn test_try_source_fallback_non_output_dir() {
1201 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1202 let mut path_to_id = FxHashMap::default();
1203 path_to_id.insert(src_path.as_path(), FileId(0));
1204
1205 let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1206 assert_eq!(
1207 try_source_fallback(&normal_path, &path_to_id),
1208 None,
1209 "non-output directory path should not trigger fallback"
1210 );
1211 }
1212
1213 #[test]
1214 fn test_try_source_fallback_nested_path() {
1215 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1216 let mut path_to_id = FxHashMap::default();
1217 path_to_id.insert(src_path.as_path(), FileId(2));
1218
1219 let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1220 assert_eq!(
1221 try_source_fallback(&dist_path, &path_to_id),
1222 Some(FileId(2)),
1223 "nested dist path should fall back to nested src path"
1224 );
1225 }
1226
1227 #[test]
1228 fn test_try_source_fallback_nested_dist_esm() {
1229 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1230 let mut path_to_id = FxHashMap::default();
1231 path_to_id.insert(src_path.as_path(), FileId(0));
1232
1233 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1234 assert_eq!(
1235 try_source_fallback(&dist_path, &path_to_id),
1236 Some(FileId(0)),
1237 "dist/esm/utils.mjs should fall back to src/utils.ts"
1238 );
1239 }
1240
1241 #[test]
1242 fn test_try_source_fallback_nested_build_cjs() {
1243 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1244 let mut path_to_id = FxHashMap::default();
1245 path_to_id.insert(src_path.as_path(), FileId(1));
1246
1247 let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1248 assert_eq!(
1249 try_source_fallback(&build_path, &path_to_id),
1250 Some(FileId(1)),
1251 "build/cjs/index.cjs should fall back to src/index.ts"
1252 );
1253 }
1254
1255 #[test]
1256 fn test_try_source_fallback_nested_dist_esm_deep_path() {
1257 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1258 let mut path_to_id = FxHashMap::default();
1259 path_to_id.insert(src_path.as_path(), FileId(2));
1260
1261 let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1262 assert_eq!(
1263 try_source_fallback(&dist_path, &path_to_id),
1264 Some(FileId(2)),
1265 "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1266 );
1267 }
1268
1269 #[test]
1270 fn test_try_source_fallback_triple_nested_output_dirs() {
1271 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1272 let mut path_to_id = FxHashMap::default();
1273 path_to_id.insert(src_path.as_path(), FileId(0));
1274
1275 let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1276 assert_eq!(
1277 try_source_fallback(&dist_path, &path_to_id),
1278 Some(FileId(0)),
1279 "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_try_source_fallback_parent_dir_named_build() {
1285 let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1286 let mut path_to_id = FxHashMap::default();
1287 path_to_id.insert(src_path.as_path(), FileId(0));
1288
1289 let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1290 assert_eq!(
1291 try_source_fallback(&dist_path, &path_to_id),
1292 Some(FileId(0)),
1293 "should resolve dist/ within project, not match parent 'build' dir"
1294 );
1295 }
1296
1297 #[test]
1298 fn package_map_exact_entry_beats_pattern_entry() {
1299 let map = serde_json::json!({
1300 "#nitro/runtime/task": "./dist/special/task.mjs",
1301 "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1302 });
1303 assert_eq!(
1304 package_map_target(&map, "#nitro/runtime/task", &conditions()),
1305 PackageMapTarget::Targets(vec!["./dist/special/task.mjs".to_string()])
1306 );
1307 }
1308
1309 #[test]
1310 fn package_map_wildcard_substitutes_capture() {
1311 let map = serde_json::json!({
1312 "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1313 });
1314 assert_eq!(
1315 package_map_target(&map, "#nitro/runtime/task", &conditions()),
1316 PackageMapTarget::Targets(vec!["./dist/runtime/internal/task.mjs".to_string()])
1317 );
1318 }
1319
1320 #[test]
1321 fn package_map_exact_entry_with_no_target_blocks_pattern_entry() {
1322 let map = serde_json::json!({
1323 "#nitro/runtime/task": null,
1324 "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1325 });
1326 assert_eq!(
1327 package_map_target(&map, "#nitro/runtime/task", &conditions()),
1328 PackageMapTarget::Blocked
1329 );
1330 }
1331
1332 #[test]
1333 fn package_map_best_pattern_with_no_target_blocks_broader_pattern() {
1334 let map = serde_json::json!({
1335 "#nitro/runtime/internal/*": null,
1336 "#nitro/runtime/*": "./dist/runtime/*.mjs"
1337 });
1338 assert_eq!(
1339 package_map_target(&map, "#nitro/runtime/internal/task", &conditions()),
1340 PackageMapTarget::Blocked
1341 );
1342 }
1343
1344 #[test]
1345 fn package_map_unmatched_subpath_is_not_a_target() {
1346 let map = serde_json::json!({
1347 "./query": "./dist/query/index.js"
1348 });
1349 assert_eq!(
1350 package_map_target(&map, "./private", &conditions()),
1351 PackageMapTarget::NoMatch
1352 );
1353 }
1354
1355 #[test]
1356 fn package_map_nested_conditions_follow_manifest_order() {
1357 let map = serde_json::json!({
1358 "./query/react": {
1359 "types": "./dist/query/react/index.d.ts",
1360 "import": {
1361 "development": "./src/query/react/index.ts",
1362 "default": "./dist/query/react/index.js"
1363 },
1364 "default": "./dist/query/react/index.cjs"
1365 }
1366 });
1367 assert_eq!(
1368 package_map_target(&map, "./query/react", &conditions()),
1369 PackageMapTarget::Targets(vec!["./dist/query/react/index.d.ts".to_string()])
1370 );
1371 }
1372
1373 #[test]
1374 fn package_map_import_before_types_selects_runtime_branch() {
1375 let map = serde_json::json!({
1376 ".": {
1377 "import": "./dist/index.js",
1378 "types": "./dist/index.d.ts"
1379 }
1380 });
1381 assert_eq!(
1382 package_map_target(&map, ".", &conditions()),
1383 PackageMapTarget::Targets(vec!["./dist/index.js".to_string()])
1384 );
1385 }
1386
1387 #[test]
1388 fn package_map_condition_order_follows_manifest_order() {
1389 let map = serde_json::json!({
1390 ".": {
1391 "node": "./dist/node.js",
1392 "import": "./dist/index.js"
1393 }
1394 });
1395 assert_eq!(
1396 package_map_target(&map, ".", &conditions()),
1397 PackageMapTarget::Targets(vec!["./dist/node.js".to_string()])
1398 );
1399 }
1400
1401 #[test]
1402 fn package_map_arrays_preserve_fallback_order() {
1403 let map = serde_json::json!({
1404 "#array": ["./dist/missing.js", "./src/array.ts"],
1405 "#null": null,
1406 "#false": false
1407 });
1408 assert_eq!(
1409 package_map_target(&map, "#array", &conditions()),
1410 PackageMapTarget::Targets(vec![
1411 "./dist/missing.js".to_string(),
1412 "./src/array.ts".to_string()
1413 ])
1414 );
1415 assert_eq!(
1416 package_map_target(&map, "#null", &conditions()),
1417 PackageMapTarget::Blocked
1418 );
1419 assert_eq!(
1420 package_map_target(&map, "#false", &conditions()),
1421 PackageMapTarget::Blocked
1422 );
1423 }
1424
1425 #[test]
1426 fn package_map_non_relative_target_does_not_trigger_source_fallback() {
1427 with_package_map_ctx(
1428 PathBuf::from("/project"),
1429 Some("pkg"),
1430 fallow_config::PackageJson::default(),
1431 &[],
1432 |ctx, manifest, _| {
1433 assert!(resolve_package_map_target(ctx, manifest, "lodash", None).is_none());
1434 assert!(
1435 resolve_package_map_target(ctx, manifest, "../dist/index.js", None).is_none()
1436 );
1437 },
1438 );
1439 }
1440
1441 #[test]
1442 fn package_map_targets_use_first_reachable_target() {
1443 let root = PathBuf::from("/project");
1444 let src_path = root.join("src/feature.ts");
1445 let targets = vec![
1446 "./dist/missing.js".to_string(),
1447 "./src/feature.ts".to_string(),
1448 ];
1449
1450 with_package_map_ctx(
1451 root,
1452 Some("pkg"),
1453 fallow_config::PackageJson::default(),
1454 &[(src_path, FileId(9))],
1455 |ctx, manifest, _| {
1456 assert_eq!(
1457 resolve_package_map_targets(ctx, manifest, &targets, None),
1458 Some(FileId(9))
1459 );
1460 },
1461 );
1462 }
1463
1464 #[test]
1465 fn package_imports_fallback_supports_external_package_targets() {
1466 let root = PathBuf::from("/project");
1467 with_package_map_ctx(
1468 root,
1469 Some("pkg"),
1470 fallow_config::PackageJson {
1471 imports: Some(serde_json::json!({
1472 "#pad": "left-pad",
1473 "#scoped": "@scope/pkg/subpath"
1474 })),
1475 ..Default::default()
1476 },
1477 &[],
1478 |ctx, _, root| {
1479 let pad = try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#pad");
1480 assert!(matches!(pad, Some(ResolveResult::NpmPackage(pkg)) if pkg == "left-pad"));
1481
1482 let scoped =
1483 try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#scoped");
1484 assert!(
1485 matches!(scoped, Some(ResolveResult::NpmPackage(pkg)) if pkg == "@scope/pkg")
1486 );
1487 },
1488 );
1489 }
1490
1491 #[test]
1492 fn package_imports_fallback_supports_unnamed_packages() {
1493 let root = PathBuf::from("/project");
1494 let src_path = root.join("src/runtime/task.ts");
1495 with_package_map_ctx(
1496 root,
1497 None,
1498 fallow_config::PackageJson {
1499 imports: Some(serde_json::json!({
1500 "#runtime/*": "./dist/runtime/*.mjs"
1501 })),
1502 ..Default::default()
1503 },
1504 &[(src_path, FileId(7))],
1505 |ctx, _, root| {
1506 let result =
1507 try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#runtime/task");
1508 assert!(matches!(
1509 result,
1510 Some(ResolveResult::InternalModule(FileId(7)))
1511 ));
1512 },
1513 );
1514 }
1515
1516 #[test]
1517 fn test_pnpm_store_path_extract_package_name() {
1518 let path =
1519 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1520 assert_eq!(
1521 extract_package_name_from_node_modules_path(&path),
1522 Some("react".to_string())
1523 );
1524 }
1525
1526 #[test]
1527 fn test_pnpm_store_path_scoped_package() {
1528 let path = PathBuf::from(
1529 "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1530 );
1531 assert_eq!(
1532 extract_package_name_from_node_modules_path(&path),
1533 Some("@babel/core".to_string())
1534 );
1535 }
1536
1537 fn conditions() -> Vec<String> {
1538 vec![
1539 "development".to_string(),
1540 "import".to_string(),
1541 "require".to_string(),
1542 "default".to_string(),
1543 "types".to_string(),
1544 "node".to_string(),
1545 ]
1546 }
1547
1548 #[test]
1549 fn test_pnpm_store_path_with_peer_deps() {
1550 let path = PathBuf::from(
1551 "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
1552 );
1553 assert_eq!(
1554 extract_package_name_from_node_modules_path(&path),
1555 Some("webpack".to_string())
1556 );
1557 }
1558
1559 #[test]
1560 fn test_try_pnpm_workspace_fallback_dist_to_src() {
1561 let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1562 let mut path_to_id = FxHashMap::default();
1563 path_to_id.insert(src_path.as_path(), FileId(0));
1564
1565 let mut workspace_roots = FxHashMap::default();
1566 let ws_root = PathBuf::from("/project/packages/ui");
1567 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1568
1569 let pnpm_path = PathBuf::from(
1570 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
1571 );
1572 assert_eq!(
1573 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1574 Some(FileId(0)),
1575 ".pnpm workspace path should fall back to src/utils.ts"
1576 );
1577 }
1578
1579 #[test]
1580 fn test_try_pnpm_workspace_fallback_direct_source() {
1581 let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1582 let mut path_to_id = FxHashMap::default();
1583 path_to_id.insert(src_path.as_path(), FileId(1));
1584
1585 let mut workspace_roots = FxHashMap::default();
1586 let ws_root = PathBuf::from("/project/packages/core");
1587 workspace_roots.insert("@myorg/core", ws_root.as_path());
1588
1589 let pnpm_path = PathBuf::from(
1590 "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
1591 );
1592 assert_eq!(
1593 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1594 Some(FileId(1)),
1595 ".pnpm workspace path with src/ should resolve directly"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_try_pnpm_workspace_fallback_non_workspace_package() {
1601 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1602
1603 let mut workspace_roots = FxHashMap::default();
1604 let ws_root = PathBuf::from("/project/packages/ui");
1605 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1606
1607 let pnpm_path =
1608 PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1609 assert_eq!(
1610 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1611 None,
1612 "non-workspace package in .pnpm should return None"
1613 );
1614 }
1615
1616 #[test]
1617 fn test_try_pnpm_workspace_fallback_unscoped_package() {
1618 let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
1619 let mut path_to_id = FxHashMap::default();
1620 path_to_id.insert(src_path.as_path(), FileId(2));
1621
1622 let mut workspace_roots = FxHashMap::default();
1623 let ws_root = PathBuf::from("/project/packages/utils");
1624 workspace_roots.insert("my-utils", ws_root.as_path());
1625
1626 let pnpm_path = PathBuf::from(
1627 "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
1628 );
1629 assert_eq!(
1630 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1631 Some(FileId(2)),
1632 "unscoped workspace package in .pnpm should resolve"
1633 );
1634 }
1635
1636 #[test]
1637 fn test_try_pnpm_workspace_fallback_nested_path() {
1638 let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1639 let mut path_to_id = FxHashMap::default();
1640 path_to_id.insert(src_path.as_path(), FileId(3));
1641
1642 let mut workspace_roots = FxHashMap::default();
1643 let ws_root = PathBuf::from("/project/packages/ui");
1644 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1645
1646 let pnpm_path = PathBuf::from(
1647 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1648 );
1649 assert_eq!(
1650 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1651 Some(FileId(3)),
1652 "nested .pnpm workspace path should resolve through source fallback"
1653 );
1654 }
1655
1656 #[test]
1657 fn test_try_pnpm_workspace_fallback_no_pnpm() {
1658 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1659 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1660
1661 let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1662 assert_eq!(
1663 try_pnpm_workspace_fallback(®ular_path, &path_to_id, &workspace_roots),
1664 None,
1665 );
1666 }
1667
1668 #[test]
1669 fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1670 let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1671 let mut path_to_id = FxHashMap::default();
1672 path_to_id.insert(src_path.as_path(), FileId(4));
1673
1674 let mut workspace_roots = FxHashMap::default();
1675 let ws_root = PathBuf::from("/project/packages/ui");
1676 workspace_roots.insert("@myorg/ui", ws_root.as_path());
1677
1678 let pnpm_path = PathBuf::from(
1679 "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1680 );
1681 assert_eq!(
1682 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1683 Some(FileId(4)),
1684 ".pnpm path with peer dep suffix should still resolve"
1685 );
1686 }
1687
1688 #[test]
1689 fn make_glob_prefix_only_no_suffix() {
1690 let pattern = fallow_types::extract::DynamicImportPattern {
1691 prefix: "./locales/".to_string(),
1692 suffix: None,
1693 span: oxc_span::Span::default(),
1694 };
1695 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*");
1696 }
1697
1698 #[test]
1699 fn make_glob_prefix_with_suffix() {
1700 let pattern = fallow_types::extract::DynamicImportPattern {
1701 prefix: "./locales/".to_string(),
1702 suffix: Some(".json".to_string()),
1703 span: oxc_span::Span::default(),
1704 };
1705 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1706 }
1707
1708 #[test]
1709 fn make_glob_passthrough_star() {
1710 let pattern = fallow_types::extract::DynamicImportPattern {
1711 prefix: "./pages/**/*.tsx".to_string(),
1712 suffix: None,
1713 span: oxc_span::Span::default(),
1714 };
1715 assert_eq!(make_glob_from_pattern(&pattern), "./pages/**/*.tsx");
1716 }
1717
1718 #[test]
1719 fn make_glob_passthrough_brace() {
1720 let pattern = fallow_types::extract::DynamicImportPattern {
1721 prefix: "./i18n/{en,de,fr}.json".to_string(),
1722 suffix: None,
1723 span: oxc_span::Span::default(),
1724 };
1725 assert_eq!(make_glob_from_pattern(&pattern), "./i18n/{en,de,fr}.json");
1726 }
1727
1728 #[test]
1729 fn make_glob_empty_prefix_no_suffix() {
1730 let pattern = fallow_types::extract::DynamicImportPattern {
1731 prefix: String::new(),
1732 suffix: None,
1733 span: oxc_span::Span::default(),
1734 };
1735 assert_eq!(make_glob_from_pattern(&pattern), "*");
1736 }
1737
1738 #[test]
1739 fn make_glob_empty_prefix_with_suffix() {
1740 let pattern = fallow_types::extract::DynamicImportPattern {
1741 prefix: String::new(),
1742 suffix: Some(".ts".to_string()),
1743 span: oxc_span::Span::default(),
1744 };
1745 assert_eq!(make_glob_from_pattern(&pattern), "*.ts");
1746 }
1747
1748 #[test]
1749 fn make_glob_template_literal_prefix_only() {
1750 let pattern = fallow_types::extract::DynamicImportPattern {
1751 prefix: "./pages/".to_string(),
1752 suffix: None,
1753 span: oxc_span::Span::default(),
1754 };
1755 assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1756 }
1757
1758 #[test]
1759 fn make_glob_template_literal_with_extension_suffix() {
1760 let pattern = fallow_types::extract::DynamicImportPattern {
1761 prefix: "./locales/".to_string(),
1762 suffix: Some(".json".to_string()),
1763 span: oxc_span::Span::default(),
1764 };
1765 assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1766 }
1767
1768 #[test]
1769 fn make_glob_template_literal_deep_prefix() {
1770 let pattern = fallow_types::extract::DynamicImportPattern {
1771 prefix: "./modules/".to_string(),
1772 suffix: None,
1773 span: oxc_span::Span::default(),
1774 };
1775 assert_eq!(make_glob_from_pattern(&pattern), "./modules/*");
1776 }
1777
1778 #[test]
1779 fn make_glob_string_concat_prefix() {
1780 let pattern = fallow_types::extract::DynamicImportPattern {
1781 prefix: "./pages/".to_string(),
1782 suffix: None,
1783 span: oxc_span::Span::default(),
1784 };
1785 assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1786 }
1787
1788 #[test]
1789 fn make_glob_string_concat_with_extension() {
1790 let pattern = fallow_types::extract::DynamicImportPattern {
1791 prefix: "./views/".to_string(),
1792 suffix: Some(".vue".to_string()),
1793 span: oxc_span::Span::default(),
1794 };
1795 assert_eq!(make_glob_from_pattern(&pattern), "./views/*.vue");
1796 }
1797
1798 #[test]
1799 fn make_glob_import_meta_glob_recursive() {
1800 let pattern = fallow_types::extract::DynamicImportPattern {
1801 prefix: "./components/**/*.vue".to_string(),
1802 suffix: None,
1803 span: oxc_span::Span::default(),
1804 };
1805 assert_eq!(
1806 make_glob_from_pattern(&pattern),
1807 "./components/**/*.vue",
1808 "import.meta.glob patterns with * should pass through as-is"
1809 );
1810 }
1811
1812 #[test]
1813 fn make_glob_import_meta_glob_brace_expansion() {
1814 let pattern = fallow_types::extract::DynamicImportPattern {
1815 prefix: "./plugins/{auth,analytics}.ts".to_string(),
1816 suffix: None,
1817 span: oxc_span::Span::default(),
1818 };
1819 assert_eq!(
1820 make_glob_from_pattern(&pattern),
1821 "./plugins/{auth,analytics}.ts",
1822 "import.meta.glob patterns with braces should pass through as-is"
1823 );
1824 }
1825
1826 #[test]
1827 fn make_glob_import_meta_glob_star_with_brace() {
1828 let pattern = fallow_types::extract::DynamicImportPattern {
1829 prefix: "./routes/**/*.{ts,tsx}".to_string(),
1830 suffix: None,
1831 span: oxc_span::Span::default(),
1832 };
1833 assert_eq!(
1834 make_glob_from_pattern(&pattern),
1835 "./routes/**/*.{ts,tsx}",
1836 "combined * and brace patterns should pass through"
1837 );
1838 }
1839
1840 #[test]
1841 fn make_glob_import_meta_glob_ignores_suffix_when_star_present() {
1842 let pattern = fallow_types::extract::DynamicImportPattern {
1843 prefix: "./*.ts".to_string(),
1844 suffix: Some(".extra".to_string()),
1845 span: oxc_span::Span::default(),
1846 };
1847 assert_eq!(
1848 make_glob_from_pattern(&pattern),
1849 "./*.ts",
1850 "when prefix has glob chars, suffix is ignored (prefix used as-is)"
1851 );
1852 }
1853
1854 #[test]
1855 fn make_glob_single_dot_prefix() {
1856 let pattern = fallow_types::extract::DynamicImportPattern {
1857 prefix: "./".to_string(),
1858 suffix: None,
1859 span: oxc_span::Span::default(),
1860 };
1861 assert_eq!(make_glob_from_pattern(&pattern), "./*");
1862 }
1863
1864 #[test]
1865 fn make_glob_prefix_without_trailing_slash() {
1866 let pattern = fallow_types::extract::DynamicImportPattern {
1867 prefix: "./config".to_string(),
1868 suffix: None,
1869 span: oxc_span::Span::default(),
1870 };
1871 assert_eq!(make_glob_from_pattern(&pattern), "./config*");
1872 }
1873
1874 #[test]
1875 fn make_glob_prefix_with_dotdot() {
1876 let pattern = fallow_types::extract::DynamicImportPattern {
1877 prefix: "../shared/".to_string(),
1878 suffix: Some(".ts".to_string()),
1879 span: oxc_span::Span::default(),
1880 };
1881 assert_eq!(make_glob_from_pattern(&pattern), "../shared/*.ts");
1882 }
1883
1884 #[test]
1885 fn test_extract_package_name_with_pnpm_plus_encoded_scope() {
1886 let path = PathBuf::from(
1887 "/project/node_modules/.pnpm/@mui+material@5.15.0/node_modules/@mui/material/index.js",
1888 );
1889 assert_eq!(
1890 extract_package_name_from_node_modules_path(&path),
1891 Some("@mui/material".to_string())
1892 );
1893 }
1894
1895 #[test]
1896 fn test_extract_package_name_windows_style_path() {
1897 let path = PathBuf::from("/project/node_modules/typescript/lib/tsc.js");
1898 assert_eq!(
1899 extract_package_name_from_node_modules_path(&path),
1900 Some("typescript".to_string())
1901 );
1902 }
1903
1904 #[test]
1905 fn test_try_source_fallback_out_dir() {
1906 let src_path = PathBuf::from("/project/packages/api/src/handler.ts");
1907 let mut path_to_id = FxHashMap::default();
1908 path_to_id.insert(src_path.as_path(), FileId(5));
1909
1910 let out_path = PathBuf::from("/project/packages/api/out/handler.js");
1911 assert_eq!(
1912 try_source_fallback(&out_path, &path_to_id),
1913 Some(FileId(5)),
1914 "out/handler.js should fall back to src/handler.ts"
1915 );
1916 }
1917
1918 #[test]
1919 fn test_try_source_fallback_mts_extension() {
1920 let src_path = PathBuf::from("/project/packages/lib/src/utils.mts");
1921 let mut path_to_id = FxHashMap::default();
1922 path_to_id.insert(src_path.as_path(), FileId(6));
1923
1924 let dist_path = PathBuf::from("/project/packages/lib/dist/utils.mjs");
1925 assert_eq!(
1926 try_source_fallback(&dist_path, &path_to_id),
1927 Some(FileId(6)),
1928 "dist/utils.mjs should fall back to src/utils.mts"
1929 );
1930 }
1931
1932 #[test]
1933 fn test_try_source_fallback_cts_extension() {
1934 let src_path = PathBuf::from("/project/packages/lib/src/config.cts");
1935 let mut path_to_id = FxHashMap::default();
1936 path_to_id.insert(src_path.as_path(), FileId(7));
1937
1938 let dist_path = PathBuf::from("/project/packages/lib/dist/config.cjs");
1939 assert_eq!(
1940 try_source_fallback(&dist_path, &path_to_id),
1941 Some(FileId(7)),
1942 "dist/config.cjs should fall back to src/config.cts"
1943 );
1944 }
1945
1946 #[test]
1947 fn test_try_source_fallback_jsx_extension() {
1948 let src_path = PathBuf::from("/project/packages/ui/src/App.jsx");
1949 let mut path_to_id = FxHashMap::default();
1950 path_to_id.insert(src_path.as_path(), FileId(8));
1951
1952 let build_path = PathBuf::from("/project/packages/ui/build/App.js");
1953 assert_eq!(
1954 try_source_fallback(&build_path, &path_to_id),
1955 Some(FileId(8)),
1956 "build/App.js should fall back to src/App.jsx"
1957 );
1958 }
1959
1960 #[test]
1961 fn test_try_source_fallback_no_file_stem() {
1962 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1963 let dist_path = PathBuf::from("/project/packages/ui/dist/");
1964 assert_eq!(
1965 try_source_fallback(&dist_path, &path_to_id),
1966 None,
1967 "directory path with no file should return None"
1968 );
1969 }
1970
1971 #[test]
1972 fn test_try_source_fallback_esm_subdir() {
1973 let src_path = PathBuf::from("/project/lib/src/index.ts");
1974 let mut path_to_id = FxHashMap::default();
1975 path_to_id.insert(src_path.as_path(), FileId(10));
1976
1977 let dist_path = PathBuf::from("/project/lib/esm/index.mjs");
1978 assert_eq!(
1979 try_source_fallback(&dist_path, &path_to_id),
1980 Some(FileId(10)),
1981 "standalone esm/ directory should fall back to src/"
1982 );
1983 }
1984
1985 #[test]
1986 fn test_try_source_fallback_cjs_subdir() {
1987 let src_path = PathBuf::from("/project/lib/src/index.ts");
1988 let mut path_to_id = FxHashMap::default();
1989 path_to_id.insert(src_path.as_path(), FileId(11));
1990
1991 let cjs_path = PathBuf::from("/project/lib/cjs/index.cjs");
1992 assert_eq!(
1993 try_source_fallback(&cjs_path, &path_to_id),
1994 Some(FileId(11)),
1995 "standalone cjs/ directory should fall back to src/"
1996 );
1997 }
1998
1999 #[test]
2000 fn test_try_pnpm_workspace_fallback_empty_after_pnpm() {
2001 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2002 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2003
2004 let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/node_modules");
2005 assert_eq!(
2006 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2007 None,
2008 "path ending at node_modules with nothing after should return None"
2009 );
2010 }
2011
2012 #[test]
2013 fn test_try_pnpm_workspace_fallback_scoped_package_only_scope() {
2014 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2015 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2016
2017 let pnpm_path =
2018 PathBuf::from("/project/node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope");
2019 assert_eq!(
2020 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2021 None,
2022 "scoped package without full name and no matching workspace should return None"
2023 );
2024 }
2025
2026 #[test]
2027 fn test_try_pnpm_workspace_fallback_no_inner_node_modules() {
2028 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2029 let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2030
2031 let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/dist/index.js");
2032 assert_eq!(
2033 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2034 None,
2035 "path without inner node_modules after .pnpm should return None"
2036 );
2037 }
2038
2039 #[test]
2040 fn test_try_pnpm_workspace_fallback_package_without_relative_path() {
2041 let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2042 let mut workspace_roots = FxHashMap::default();
2043 let ws_root = PathBuf::from("/project/packages/ui");
2044 workspace_roots.insert("@myorg/ui", ws_root.as_path());
2045
2046 let pnpm_path =
2047 PathBuf::from("/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui");
2048 assert_eq!(
2049 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2050 None,
2051 "path ending at package name with no relative file should return None"
2052 );
2053 }
2054
2055 #[test]
2056 fn test_try_pnpm_workspace_fallback_nested_dist_esm() {
2057 let src_path = PathBuf::from("/project/packages/ui/src/Button.ts");
2058 let mut path_to_id = FxHashMap::default();
2059 path_to_id.insert(src_path.as_path(), FileId(10));
2060
2061 let mut workspace_roots = FxHashMap::default();
2062 let ws_root = PathBuf::from("/project/packages/ui");
2063 workspace_roots.insert("@myorg/ui", ws_root.as_path());
2064
2065 let pnpm_path = PathBuf::from(
2066 "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/esm/Button.mjs",
2067 );
2068 assert_eq!(
2069 try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2070 Some(FileId(10)),
2071 "pnpm path with nested dist/esm should resolve through source fallback"
2072 );
2073 }
2074}