Skip to main content

fallow_graph/resolve/
fallbacks.rs

1//! Resolution fallback strategies for import specifiers.
2//!
3//! Handles path alias fallbacks, output-to-source directory mapping, pnpm virtual
4//! store detection, node_modules package extraction, and dynamic import glob patterns.
5
6use 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
16/// Return the post-prefix remainder when `specifier` matches the alias `prefix`
17/// at a path boundary, else `None`.
18///
19/// The match is segment-aware: a bare exact-key alias (e.g. `@scope/sdk` or
20/// `vscode`) matches only on an exact hit or a `/`-delimited continuation, so it
21/// never captures a longer package that merely shares the prefix
22/// (`@scope/sdk-extra`). Prefixes that already end in `/` (`~/`, `@/`, `$lib/`)
23/// match any continuation by construction, preserving their existing behavior.
24fn 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
30/// Try resolving a specifier using plugin-provided path aliases.
31///
32/// Substitutes a matching alias prefix (e.g., `~/`) with a directory relative to the
33/// project root (e.g., `app/`) and resolves the resulting path. This handles framework
34/// aliases like Nuxt's `~/`, `~~/`, `#shared/` that aren't defined in tsconfig.json
35/// but map to real filesystem paths.
36pub(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
78/// Try SCSS partial resolution: `_filename` and `_index` conventions.
79///
80/// SCSS resolves imports in this order:
81/// 1. `@use 'variables'` → `_variables.scss` (partial convention)
82/// 2. `@use 'components'` → `components/_index.scss` or `components/index.scss` (directory index)
83///
84/// Handles both relative (`../styles/variables`) and bare (`variables`) specifiers
85/// that were normalized to `./variables` during extraction.
86pub(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
124/// Try non-partial CSS-extension resolution: `<spec>.scss`, `<spec>.sass`,
125/// `<spec>.css` from the importing file's parent.
126///
127/// This is needed when the standard resolver's extension list contains both
128/// `.vue` / `.svelte` / `.astro` AND CSS extensions. For an SFC `<style>` block
129/// importing `./Foo`, the standard resolver picks `Foo.vue` (the SFC itself!)
130/// before `Foo.scss` because `.vue` comes earlier in the extension list. SCSS
131/// imports must restrict resolution to CSS-family extensions to avoid this
132/// self-import collision. Only invoked when `from_style = true`. See issue #195.
133pub(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
162/// Attempt to resolve a single SCSS specifier and map to an internal module.
163fn 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
182/// Try SCSS `includePaths` fallback: resolve the specifier against each
183/// framework-contributed include directory.
184///
185/// Angular's `stylePreprocessorOptions.includePaths` (and Nx's equivalent via
186/// project.json) adds extra search paths that SCSS resolves against before
187/// falling back to node_modules. Bare `@use 'variables'` statements that were
188/// normalized to `./variables` at extraction time fail the usual file-local
189/// resolution, so when the importing file is `.scss`/`.sass` and the spec
190/// originated from such a bare specifier, we retry against each include path,
191/// applying the SCSS partial (`_variables`) and directory-index conventions.
192/// SFC `<style lang="scss">` imports pass `from_style = true` because their
193/// filesystem importer is `.vue` / `.svelte`, not `.scss` / `.sass`.
194///
195/// The specifier arrives with a `./` prefix because `normalize_css_import_path`
196/// rewrites bare extensionless SCSS specifiers to relative ones. We strip that
197/// prefix here to re-enter the include-path search from the root of each
198/// directory. Relative specifiers that already escape the importing file
199/// (e.g. `../shared/variables`) are left untouched — include paths only
200/// disambiguate bare specifiers, not explicit relative paths.
201pub(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
232/// Probe an SCSS include directory for a bare specifier, applying the standard
233/// SCSS resolution order: exact file, `_`-prefixed partial, `_index` / `index`
234/// directory conventions. Supports `.scss` and `.sass` extensions.
235fn 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
295/// Look up an absolute candidate path in the file index, falling back to
296/// canonical path lookup for intra-project symlinks.
297fn 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
314/// Try SCSS `node_modules` fallback: resolve a bare specifier by walking up
315/// from the importing file and probing each ancestor's `node_modules/` dir.
316///
317/// Sass's `@import` / `@use` resolution algorithm searches `node_modules/` for
318/// bare specifiers after the file-local and `includePaths` searches fail.
319/// `@import 'bootstrap/scss/functions'` resolves to
320/// `node_modules/bootstrap/scss/_functions.scss` via the standard partial
321/// convention; `@import 'animate.css/animate.min'` resolves to
322/// `node_modules/animate.css/animate.min.css` via the CSS-extension fallback.
323///
324/// Files inside `node_modules/` are not in fallow's file index (the default
325/// ignore patterns exclude them), so this function returns
326/// `ResolveResult::NpmPackage` when a candidate exists on disk. That ensures
327/// (1) the `@import` is not reported as unresolved and (2) the npm package is
328/// marked as a used dependency so `unused-dependencies` / `unlisted-dependencies`
329/// stay accurate.
330///
331/// The specifier arrives with a `./` prefix because `normalize_css_import_path`
332/// rewrites bare extensionless SCSS specifiers to relative ones. Parent-relative
333/// specifiers are skipped — they explicitly escape the importing file and must
334/// not be silently redirected to `node_modules`. See issue #125.
335pub(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
375/// Probe candidate filesystem paths for a bare SCSS specifier inside a single
376/// `node_modules/` directory, applying Sass resolution conventions.
377///
378/// Candidate order:
379/// 1. `<bare>.scss` / `<bare>.sass` / `<bare>.css` (extension append)
380/// 2. `<parent>/_<stem>.scss` / `<parent>/_<stem>.sass` (partial convention)
381/// 3. `<bare>/_index.scss` / `<bare>/index.scss` (and `.sass` variants)
382/// 4. `<bare>` (exact, for specifiers that already carry an extension)
383fn 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
420/// Try to map a resolved output path (e.g., `packages/ui/dist/utils.js`) back to
421/// the corresponding source file (e.g., `packages/ui/src/utils.ts`).
422///
423/// This handles cross-workspace imports that go through `exports` maps pointing to
424/// built output directories. Since fallow ignores `dist/`, `build/`, etc. by default,
425/// the resolved path won't be in the file set, but the source file will be.
426///
427/// Nested output subdirectories (e.g., `dist/esm/utils.mjs`, `build/cjs/index.cjs`)
428/// are handled by finding the last output directory component (closest to the file,
429/// avoiding false matches on parent directories) and then walking backwards to collect
430/// all consecutive output directory components before it.
431pub(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()?; // Ensure the suffix has a filename
457
458    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
468/// Try to resolve a package `imports` entry from the nearest owning package.
469///
470/// `#...` specifiers are package-local by definition, so this fallback is only
471/// allowed when the importing file's nearest package manifest has a matching
472/// `imports` key. That keeps unrelated hash-prefixed path aliases unresolved.
473pub(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
780/// Extract npm package name from a resolved path inside `node_modules`.
781///
782/// Given a path like `/project/node_modules/react/index.js`, returns `Some("react")`.
783/// Given a path like `/project/node_modules/@scope/pkg/dist/index.js`, returns `Some("@scope/pkg")`.
784/// Returns `None` if the path doesn't contain a `node_modules` segment.
785pub 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
812/// Try to map a pnpm virtual store path back to a workspace source file.
813///
814/// When pnpm uses injected dependencies or certain linking strategies, canonical
815/// paths go through `.pnpm`:
816///   `/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/index.js`
817///
818/// This function detects such paths, extracts the package name, checks if it
819/// matches a workspace package, and tries to find the source file in that workspace.
820pub(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
871/// Try to resolve a bare specifier as a workspace package reference.
872///
873/// When the specifier's package name matches a workspace package, resolve the
874/// subpath against that package's root directory directly instead of going
875/// through `node_modules`. Covers two cases:
876///
877/// 1. **Self-referencing package imports**: Node.js v12+ lets a package import
878///    itself via its own name (`import { X } from '@org/pkg/subentry'` from
879///    inside `@org/pkg`). Angular libraries built with `ng-packagr` rely on
880///    this to declare secondary entry points.
881/// 2. **Cross-workspace imports without `node_modules` symlinks**: monorepos
882///    that have not been installed yet, or bundlers that bypass `node_modules`
883///    entirely, still need to resolve `@org/other-pkg/sub` to the sibling
884///    workspace's source file.
885///
886/// Strategy: prefer a matching package `exports` target when the manifest has
887/// one, then try the package source layout directly when no `exports` map exists,
888/// and finally resolve the stripped subpath as a relative path from inside the
889/// package root. The manifest branches cover source-only workspaces whose
890/// package metadata points at missing `dist` output.
891///
892/// See issues #106, #641, and #725.
893pub(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
987/// Convert a `DynamicImportPattern` to a glob string for file matching.
988pub(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(&regular_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}