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::types::{OUTPUT_DIRS, PackageManifestInfo, ResolveContext, ResolveResult, SOURCE_EXTS};
14
15/// Try resolving a specifier using plugin-provided path aliases.
16///
17/// Substitutes a matching alias prefix (e.g., `~/`) with a directory relative to the
18/// project root (e.g., `app/`) and resolves the resulting path. This handles framework
19/// aliases like Nuxt's `~/`, `~~/`, `#shared/` that aren't defined in tsconfig.json
20/// but map to real filesystem paths.
21pub(super) fn try_path_alias_fallback(
22    ctx: &ResolveContext<'_>,
23    specifier: &str,
24) -> Option<ResolveResult> {
25    for (prefix, replacement) in ctx.path_aliases {
26        if !specifier.starts_with(prefix.as_str()) {
27            continue;
28        }
29
30        let remainder = &specifier[prefix.len()..];
31        // Build the substituted path relative to root.
32        // If replacement is empty, remainder is relative to root directly.
33        let substituted = if replacement.is_empty() {
34            format!("./{remainder}")
35        } else {
36            format!("./{replacement}/{remainder}")
37        };
38
39        // Resolve relative to the project root directly. These plugin-provided
40        // aliases have already been normalized to root-relative paths, so
41        // tsconfig discovery is not needed here and can actually hurt for
42        // solution-style roots (`tsconfig.json` with only `references`).
43        if let Ok(resolved) = ctx.resolver.resolve(ctx.root, &substituted) {
44            let resolved_path = resolved.path();
45            // Try raw path lookup first
46            if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
47                return Some(ResolveResult::InternalModule(file_id));
48            }
49            // Fall back to canonical path lookup
50            if let Ok(canonical) = dunce::canonicalize(resolved_path) {
51                if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
52                    return Some(ResolveResult::InternalModule(file_id));
53                }
54                if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
55                    return Some(ResolveResult::InternalModule(file_id));
56                }
57                if let Some(file_id) =
58                    try_pnpm_workspace_fallback(&canonical, ctx.path_to_id, ctx.workspace_roots)
59                {
60                    return Some(ResolveResult::InternalModule(file_id));
61                }
62                if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
63                    return Some(ResolveResult::NpmPackage(pkg_name));
64                }
65                return Some(ResolveResult::ExternalFile(canonical));
66            }
67        }
68    }
69    None
70}
71
72/// Try SCSS partial resolution: `_filename` and `_index` conventions.
73///
74/// SCSS resolves imports in this order:
75/// 1. `@use 'variables'` → `_variables.scss` (partial convention)
76/// 2. `@use 'components'` → `components/_index.scss` or `components/index.scss` (directory index)
77///
78/// Handles both relative (`../styles/variables`) and bare (`variables`) specifiers
79/// that were normalized to `./variables` during extraction.
80pub(super) fn try_scss_partial_fallback(
81    ctx: &ResolveContext<'_>,
82    from_file: &Path,
83    specifier: &str,
84) -> Option<ResolveResult> {
85    // SCSS built-in modules (`sass:math`) should not be retried
86    if specifier.contains(':') {
87        return None;
88    }
89
90    let spec_path = Path::new(specifier);
91    let filename = spec_path.file_name()?.to_str()?;
92
93    // Already has underscore prefix
94    if filename.starts_with('_') {
95        return None;
96    }
97
98    // 1. Try partial convention: prepend _ to the filename
99    let partial_filename = format!("_{filename}");
100    let partial_specifier = if let Some(parent) = spec_path.parent()
101        && !parent.as_os_str().is_empty()
102    {
103        format!("{}/{partial_filename}", parent.display())
104    } else {
105        partial_filename
106    };
107
108    if let Some(result) = try_resolve_scss(ctx, from_file, &partial_specifier) {
109        return Some(result);
110    }
111
112    // 2. Try directory index convention: specifier/_index and specifier/index
113    let index_partial = format!("{specifier}/_index");
114    if let Some(result) = try_resolve_scss(ctx, from_file, &index_partial) {
115        return Some(result);
116    }
117
118    let index_plain = format!("{specifier}/index");
119    try_resolve_scss(ctx, from_file, &index_plain)
120}
121
122/// Try non-partial CSS-extension resolution: `<spec>.scss`, `<spec>.sass`,
123/// `<spec>.css` from the importing file's parent.
124///
125/// This is needed when the standard resolver's extension list contains both
126/// `.vue` / `.svelte` / `.astro` AND CSS extensions. For an SFC `<style>` block
127/// importing `./Foo`, the standard resolver picks `Foo.vue` (the SFC itself!)
128/// before `Foo.scss` because `.vue` comes earlier in the extension list. SCSS
129/// imports must restrict resolution to CSS-family extensions to avoid this
130/// self-import collision. Only invoked when `from_style = true`. See issue #195.
131pub(super) fn try_css_extension_fallback(
132    ctx: &ResolveContext<'_>,
133    from_file: &Path,
134    specifier: &str,
135) -> Option<ResolveResult> {
136    if specifier.contains(':') {
137        return None;
138    }
139    // If the specifier already has a CSS extension, the standard resolver path
140    // would have found it by name; a fallback re-entry with the same suffix is
141    // a no-op.
142    let spec_path = Path::new(specifier);
143    let already_css_ext = spec_path
144        .extension()
145        .and_then(|e| e.to_str())
146        .is_some_and(|e| {
147            e.eq_ignore_ascii_case("css")
148                || e.eq_ignore_ascii_case("scss")
149                || e.eq_ignore_ascii_case("sass")
150        });
151    if already_css_ext {
152        return try_resolve_scss(ctx, from_file, specifier);
153    }
154    for ext in ["scss", "sass", "css"] {
155        let candidate = format!("{specifier}.{ext}");
156        if let Some(result) = try_resolve_scss(ctx, from_file, &candidate) {
157            return Some(result);
158        }
159    }
160    None
161}
162
163/// Attempt to resolve a single SCSS specifier and map to an internal module.
164fn try_resolve_scss(
165    ctx: &ResolveContext<'_>,
166    from_file: &Path,
167    specifier: &str,
168) -> Option<ResolveResult> {
169    let resolved = ctx.resolver.resolve_file(from_file, specifier).ok()?;
170    let resolved_path = resolved.path();
171
172    if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
173        return Some(ResolveResult::InternalModule(file_id));
174    }
175    if let Ok(canonical) = dunce::canonicalize(resolved_path)
176        && let Some(&file_id) = ctx.path_to_id.get(canonical.as_path())
177    {
178        return Some(ResolveResult::InternalModule(file_id));
179    }
180    None
181}
182
183/// Try SCSS `includePaths` fallback: resolve the specifier against each
184/// framework-contributed include directory.
185///
186/// Angular's `stylePreprocessorOptions.includePaths` (and Nx's equivalent via
187/// project.json) adds extra search paths that SCSS resolves against before
188/// falling back to node_modules. Bare `@use 'variables'` statements that were
189/// normalized to `./variables` at extraction time fail the usual file-local
190/// resolution, so when the importing file is `.scss`/`.sass` and the spec
191/// originated from such a bare specifier, we retry against each include path,
192/// applying the SCSS partial (`_variables`) and directory-index conventions.
193/// SFC `<style lang="scss">` imports pass `from_style = true` because their
194/// filesystem importer is `.vue` / `.svelte`, not `.scss` / `.sass`.
195///
196/// The specifier arrives with a `./` prefix because `normalize_css_import_path`
197/// rewrites bare extensionless SCSS specifiers to relative ones. We strip that
198/// prefix here to re-enter the include-path search from the root of each
199/// directory. Relative specifiers that already escape the importing file
200/// (e.g. `../shared/variables`) are left untouched — include paths only
201/// disambiguate bare specifiers, not explicit relative paths.
202pub(super) fn try_scss_include_path_fallback(
203    ctx: &ResolveContext<'_>,
204    from_file: &Path,
205    specifier: &str,
206    from_style: bool,
207) -> Option<ResolveResult> {
208    if ctx.scss_include_paths.is_empty() {
209        return None;
210    }
211    let is_scss_importer = from_file
212        .extension()
213        .is_some_and(|e| e == "scss" || e == "sass");
214    if !is_scss_importer && !from_style {
215        return None;
216    }
217    // SCSS built-in modules (`sass:math`) should not be retried
218    if specifier.contains(':') {
219        return None;
220    }
221    // Only bare (normalized) specifiers benefit from include-path search.
222    // Parent-relative specifiers like `../shared/vars` explicitly escape the
223    // importing file's directory and should not be silently redirected.
224    let bare = specifier.strip_prefix("./")?;
225    if bare.starts_with("..") || bare.starts_with('/') {
226        return None;
227    }
228
229    for include_dir in ctx.scss_include_paths {
230        if let Some(file_id) = find_scss_in_dir(include_dir, bare, ctx) {
231            return Some(ResolveResult::InternalModule(file_id));
232        }
233    }
234    None
235}
236
237/// Probe an SCSS include directory for a bare specifier, applying the standard
238/// SCSS resolution order: exact file, `_`-prefixed partial, `_index` / `index`
239/// directory conventions. Supports `.scss` and `.sass` extensions.
240fn find_scss_in_dir(include_dir: &Path, bare: &str, ctx: &ResolveContext<'_>) -> Option<FileId> {
241    let bare_path = Path::new(bare);
242    let has_scss_ext = matches!(
243        bare_path.extension().and_then(|e| e.to_str()),
244        Some(ext) if ext.eq_ignore_ascii_case("scss") || ext.eq_ignore_ascii_case("sass")
245    );
246
247    // Split bare spec so we can build the `_`-prefixed partial for the final
248    // component while preserving any leading directory segments.
249    let parent = bare_path.parent();
250    let stem_with_ext = bare_path.file_name()?.to_str()?;
251    let stem_without_ext = bare_path.file_stem().and_then(|s| s.to_str())?;
252
253    let build = |rel: &Path| -> std::path::PathBuf { include_dir.join(rel) };
254    let join_with_parent = |name: &str| -> std::path::PathBuf {
255        parent.map_or_else(|| build(Path::new(name)), |p| build(&p.join(name)))
256    };
257
258    let exts: &[&str] = if has_scss_ext {
259        &[""]
260    } else {
261        &["scss", "sass"]
262    };
263
264    for ext in exts {
265        let suffix = if ext.is_empty() {
266            String::new()
267        } else {
268            format!(".{ext}")
269        };
270        // 1. Direct file: include_dir/<bare><ext>
271        let direct = if ext.is_empty() {
272            build(bare_path)
273        } else {
274            join_with_parent(&format!("{stem_with_ext}{suffix}"))
275        };
276        if let Some(fid) = lookup_scss_path(&direct, ctx) {
277            return Some(fid);
278        }
279        // 2. Partial: include_dir/<parent>/_<stem><ext>
280        let partial_name = if ext.is_empty() {
281            format!("_{stem_with_ext}")
282        } else {
283            format!("_{stem_without_ext}{suffix}")
284        };
285        let partial = join_with_parent(&partial_name);
286        if let Some(fid) = lookup_scss_path(&partial, ctx) {
287            return Some(fid);
288        }
289        if ext.is_empty() {
290            // Already has extension; directory index candidates below don't apply.
291            continue;
292        }
293        // 3. Directory index: include_dir/<bare>/_index.<ext>
294        let idx_partial = build(bare_path).join(format!("_index{suffix}"));
295        if let Some(fid) = lookup_scss_path(&idx_partial, ctx) {
296            return Some(fid);
297        }
298        let idx_plain = build(bare_path).join(format!("index{suffix}"));
299        if let Some(fid) = lookup_scss_path(&idx_plain, ctx) {
300            return Some(fid);
301        }
302    }
303    None
304}
305
306/// Look up an absolute candidate path in the file index, falling back to
307/// canonical path lookup for intra-project symlinks.
308fn lookup_scss_path(candidate: &Path, ctx: &ResolveContext<'_>) -> Option<FileId> {
309    if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
310        return Some(file_id);
311    }
312    if let Ok(canonical) = dunce::canonicalize(candidate) {
313        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
314            return Some(file_id);
315        }
316        if let Some(fallback) = ctx.canonical_fallback
317            && let Some(file_id) = fallback.get(&canonical)
318        {
319            return Some(file_id);
320        }
321    }
322    None
323}
324
325/// Try SCSS `node_modules` fallback: resolve a bare specifier by walking up
326/// from the importing file and probing each ancestor's `node_modules/` dir.
327///
328/// Sass's `@import` / `@use` resolution algorithm searches `node_modules/` for
329/// bare specifiers after the file-local and `includePaths` searches fail.
330/// `@import 'bootstrap/scss/functions'` resolves to
331/// `node_modules/bootstrap/scss/_functions.scss` via the standard partial
332/// convention; `@import 'animate.css/animate.min'` resolves to
333/// `node_modules/animate.css/animate.min.css` via the CSS-extension fallback.
334///
335/// Files inside `node_modules/` are not in fallow's file index (the default
336/// ignore patterns exclude them), so this function returns
337/// `ResolveResult::NpmPackage` when a candidate exists on disk. That ensures
338/// (1) the `@import` is not reported as unresolved and (2) the npm package is
339/// marked as a used dependency so `unused-dependencies` / `unlisted-dependencies`
340/// stay accurate.
341///
342/// The specifier arrives with a `./` prefix because `normalize_css_import_path`
343/// rewrites bare extensionless SCSS specifiers to relative ones. Parent-relative
344/// specifiers are skipped — they explicitly escape the importing file and must
345/// not be silently redirected to `node_modules`. See issue #125.
346pub(super) fn try_scss_node_modules_fallback(
347    _ctx: &ResolveContext<'_>,
348    from_file: &Path,
349    specifier: &str,
350    from_style: bool,
351) -> Option<ResolveResult> {
352    // SCSS built-in modules (`sass:math`) should not be retried
353    if specifier.contains(':') {
354        return None;
355    }
356    let is_scss_importer = from_file
357        .extension()
358        .is_some_and(|e| e == "scss" || e == "sass");
359    if !is_scss_importer && !from_style {
360        return None;
361    }
362    // Only bare (normalized) specifiers should search node_modules. Explicit
363    // parent-relative paths (`../shared/vars`) are intentional and must not be
364    // redirected.
365    let bare = specifier.strip_prefix("./")?;
366    if bare.starts_with("..") || bare.starts_with('/') {
367        return None;
368    }
369    // The first segment of a bare specifier is the package name (or the start
370    // of a scoped package name). Require it before probing node_modules to
371    // avoid spurious syscalls on malformed specifiers.
372    if bare.is_empty() {
373        return None;
374    }
375
376    // Walk up from the importing file's parent directory to the filesystem
377    // root, matching Node.js / Sass `node_modules` resolution. Covers all
378    // common layouts: flat single project, non-hoisted monorepo, and hoisted
379    // monorepo where `node_modules` lives above the fallow project root
380    // (e.g., fallow run on `/monorepo/packages/my-lib` needs to reach
381    // `/monorepo/node_modules`). The walk is bounded by `Path::parent()`
382    // returning `None` at the filesystem root.
383    let mut dir = from_file.parent()?;
384    loop {
385        let nm_dir = dir.join("node_modules");
386        if nm_dir.is_dir()
387            && let Some(path) = find_scss_in_node_modules(&nm_dir, bare)
388            && let Some(pkg_name) = extract_package_name_from_node_modules_path(&path)
389        {
390            return Some(ResolveResult::NpmPackage(pkg_name));
391        }
392        let Some(parent) = dir.parent() else {
393            break;
394        };
395        dir = parent;
396    }
397    None
398}
399
400/// Probe candidate filesystem paths for a bare SCSS specifier inside a single
401/// `node_modules/` directory, applying Sass resolution conventions.
402///
403/// Candidate order:
404/// 1. `<bare>.scss` / `<bare>.sass` / `<bare>.css` (extension append)
405/// 2. `<parent>/_<stem>.scss` / `<parent>/_<stem>.sass` (partial convention)
406/// 3. `<bare>/_index.scss` / `<bare>/index.scss` (and `.sass` variants)
407/// 4. `<bare>` (exact, for specifiers that already carry an extension)
408fn find_scss_in_node_modules(nm_dir: &Path, bare: &str) -> Option<PathBuf> {
409    let bare_path = Path::new(bare);
410    let file_name = bare_path.file_name()?.to_str()?;
411    let parent = bare_path.parent();
412    let join_with_parent = |name: &str| -> PathBuf {
413        parent.map_or_else(|| nm_dir.join(name), |p| nm_dir.join(p).join(name))
414    };
415
416    // 1. Append extension. Covers both SCSS partials (with ext .scss/.sass
417    // added via the separate partial probe below) and CSS files where Sass
418    // appends `.css` to an extensionless specifier like `animate.css/animate.min`.
419    for ext in &["scss", "sass", "css"] {
420        let candidate = join_with_parent(&format!("{file_name}.{ext}"));
421        if candidate.is_file() {
422            return Some(candidate);
423        }
424    }
425    // 2. SCSS partial: prepend underscore to the file name component only.
426    // Skip `.css` here — CSS has no partial convention.
427    for ext in &["scss", "sass"] {
428        let candidate = join_with_parent(&format!("_{file_name}.{ext}"));
429        if candidate.is_file() {
430            return Some(candidate);
431        }
432    }
433    // 3. Directory index: `<bare>/_index.<ext>` or `<bare>/index.<ext>`.
434    for ext in &["scss", "sass"] {
435        let idx_partial = nm_dir.join(bare).join(format!("_index.{ext}"));
436        if idx_partial.is_file() {
437            return Some(idx_partial);
438        }
439        let idx_plain = nm_dir.join(bare).join(format!("index.{ext}"));
440        if idx_plain.is_file() {
441            return Some(idx_plain);
442        }
443    }
444    // 4. Exact file — covers specifiers that already carry an extension
445    // (e.g., `bootstrap/dist/css/bootstrap.min.css`).
446    let exact = nm_dir.join(bare);
447    if exact.is_file() {
448        return Some(exact);
449    }
450    None
451}
452
453/// Try to map a resolved output path (e.g., `packages/ui/dist/utils.js`) back to
454/// the corresponding source file (e.g., `packages/ui/src/utils.ts`).
455///
456/// This handles cross-workspace imports that go through `exports` maps pointing to
457/// built output directories. Since fallow ignores `dist/`, `build/`, etc. by default,
458/// the resolved path won't be in the file set, but the source file will be.
459///
460/// Nested output subdirectories (e.g., `dist/esm/utils.mjs`, `build/cjs/index.cjs`)
461/// are handled by finding the last output directory component (closest to the file,
462/// avoiding false matches on parent directories) and then walking backwards to collect
463/// all consecutive output directory components before it.
464pub(super) fn try_source_fallback(
465    resolved: &Path,
466    path_to_id: &FxHashMap<&Path, FileId>,
467) -> Option<FileId> {
468    let components: Vec<_> = resolved.components().collect();
469
470    let is_output_dir = |c: &std::path::Component| -> bool {
471        if let std::path::Component::Normal(s) = c
472            && let Some(name) = s.to_str()
473        {
474            return OUTPUT_DIRS.contains(&name);
475        }
476        false
477    };
478
479    // Find the LAST output directory component (closest to the file).
480    // Using rposition avoids false matches on parent directories that happen to
481    // be named "build", "dist", etc.
482    let last_output_pos = components.iter().rposition(&is_output_dir)?;
483
484    // Walk backwards to find the start of consecutive output directory components.
485    // e.g., for `dist/esm/utils.mjs`, rposition finds `esm`, then we walk back to `dist`.
486    let mut first_output_pos = last_output_pos;
487    while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
488        first_output_pos -= 1;
489    }
490
491    // Build the path prefix (everything before the first consecutive output dir)
492    let prefix: PathBuf = components[..first_output_pos].iter().collect();
493
494    // Build the relative path after the last consecutive output dir
495    let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
496    suffix.file_stem()?; // Ensure the suffix has a filename
497
498    // Try replacing the output dirs with "src" and each source extension
499    for ext in SOURCE_EXTS {
500        let source_candidate = prefix.join("src").join(suffix.with_extension(ext));
501        if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
502            return Some(file_id);
503        }
504    }
505
506    None
507}
508
509/// Try to resolve a package `imports` entry from the nearest owning package.
510///
511/// `#...` specifiers are package-local by definition, so this fallback is only
512/// allowed when the importing file's nearest package manifest has a matching
513/// `imports` key. That keeps unrelated hash-prefixed path aliases unresolved.
514pub(super) fn try_package_imports_fallback(
515    ctx: &ResolveContext<'_>,
516    from_file: &Path,
517    specifier: &str,
518) -> Option<ResolveResult> {
519    if !specifier.starts_with('#') {
520        return None;
521    }
522    let manifest = nearest_package_manifest(ctx.package_manifests, from_file)?;
523    let imports = manifest.package_json.imports.as_ref()?;
524    let PackageMapTarget::Target(target) =
525        package_map_target(imports, specifier, ctx.condition_names)
526    else {
527        return None;
528    };
529    let source_subpath = package_import_source_subpath(manifest, specifier);
530    resolve_package_map_target(ctx, manifest, &target, source_subpath.as_deref()).map(|file_id| {
531        match &manifest.name {
532            Some(package_name) => ResolveResult::InternalPackageModule {
533                file_id,
534                package_name: package_name.clone(),
535            },
536            None => ResolveResult::InternalModule(file_id),
537        }
538    })
539}
540
541#[derive(Debug, Clone, PartialEq, Eq)]
542enum PackageMapTarget {
543    NoMatch,
544    Blocked,
545    Target(String),
546}
547
548fn package_map_match_value(
549    value: &Value,
550    condition_names: &[String],
551    capture: Option<&str>,
552) -> PackageMapTarget {
553    resolve_package_map_value(value, condition_names, capture)
554        .map_or(PackageMapTarget::Blocked, PackageMapTarget::Target)
555}
556
557fn package_map_target(
558    map: &Value,
559    specifier_key: &str,
560    condition_names: &[String],
561) -> PackageMapTarget {
562    let Some(obj) = map.as_object() else {
563        if specifier_key == "." {
564            return package_map_match_value(map, condition_names, None);
565        }
566        return PackageMapTarget::NoMatch;
567    };
568
569    let has_subpath_keys = obj
570        .keys()
571        .any(|key| key == "." || key.starts_with("./") || key.starts_with('#'));
572    if !has_subpath_keys {
573        if specifier_key == "." {
574            return package_map_match_value(map, condition_names, None);
575        }
576        return PackageMapTarget::NoMatch;
577    }
578
579    if let Some(value) = obj.get(specifier_key) {
580        return package_map_match_value(value, condition_names, None);
581    }
582
583    let mut patterns: Vec<(&str, &Value, String)> = obj
584        .iter()
585        .filter_map(|(pattern, value)| {
586            package_map_pattern_capture(pattern, specifier_key)
587                .map(|capture| (pattern.as_str(), value, capture))
588        })
589        .collect();
590    patterns.sort_by(|(left, _, _), (right, _, _)| {
591        package_map_pattern_specificity(right).cmp(&package_map_pattern_specificity(left))
592    });
593
594    patterns
595        .first()
596        .map_or(PackageMapTarget::NoMatch, |(_, value, capture)| {
597            package_map_match_value(value, condition_names, Some(capture))
598        })
599}
600
601fn resolve_package_map_value(
602    value: &Value,
603    condition_names: &[String],
604    capture: Option<&str>,
605) -> Option<String> {
606    match value {
607        Value::String(target) => Some(match capture {
608            Some(capture) => target.replace('*', capture),
609            None => target.clone(),
610        }),
611        Value::Object(map) => {
612            for (condition, value) in map {
613                if (condition == "default"
614                    || condition_names
615                        .iter()
616                        .any(|active_condition| active_condition == condition))
617                    && let Some(target) = resolve_package_map_value(value, condition_names, capture)
618                {
619                    return Some(target);
620                }
621            }
622            None
623        }
624        Value::Array(_) | Value::Bool(_) | Value::Null | Value::Number(_) => None,
625    }
626}
627
628fn package_map_pattern_capture(pattern: &str, specifier: &str) -> Option<String> {
629    let star = pattern.find('*')?;
630    if pattern[star + 1..].contains('*') {
631        return None;
632    }
633    let (prefix, suffix_with_star) = pattern.split_at(star);
634    let suffix = &suffix_with_star[1..];
635    let captured = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
636    Some(captured.to_string())
637}
638
639fn package_map_pattern_specificity(pattern: &str) -> (usize, usize) {
640    let star = pattern.find('*').unwrap_or(pattern.len());
641    (star, pattern.len())
642}
643
644fn package_import_source_subpath(
645    manifest: &PackageManifestInfo,
646    specifier: &str,
647) -> Option<PathBuf> {
648    let stripped = specifier.strip_prefix('#')?;
649    let without_package_name = manifest
650        .name
651        .as_deref()
652        .and_then(|name| stripped.strip_prefix(name))
653        .and_then(|rest| rest.strip_prefix('/'))
654        .unwrap_or(stripped);
655    if without_package_name.is_empty() {
656        None
657    } else {
658        Some(PathBuf::from(without_package_name))
659    }
660}
661
662fn nearest_package_manifest<'a>(
663    manifests: &'a [PackageManifestInfo],
664    from_file: &Path,
665) -> Option<&'a PackageManifestInfo> {
666    manifests
667        .iter()
668        .filter(|manifest| {
669            from_file.starts_with(&manifest.root) || from_file.starts_with(&manifest.canonical_root)
670        })
671        .max_by_key(|manifest| manifest.root.components().count())
672}
673
674fn find_package_manifest<'a>(
675    manifests: &'a [PackageManifestInfo],
676    package_name: &str,
677) -> Option<&'a PackageManifestInfo> {
678    manifests
679        .iter()
680        .find(|manifest| manifest.name.as_deref() == Some(package_name))
681}
682
683fn resolve_package_map_target(
684    ctx: &ResolveContext<'_>,
685    manifest: &PackageManifestInfo,
686    target: &str,
687    source_subpath: Option<&Path>,
688) -> Option<FileId> {
689    let target = target.strip_prefix("./")?;
690    if target.starts_with("../") || target.starts_with('/') {
691        return None;
692    }
693    let target_path = manifest.root.join(target);
694
695    lookup_internal_file_id(ctx, &target_path)
696        .or_else(|| try_source_fallback(&target_path, ctx.raw_path_to_id))
697        .or_else(|| try_source_fallback(&target_path, ctx.path_to_id))
698        .or_else(|| source_subpath.and_then(|subpath| try_source_subpath(ctx, manifest, subpath)))
699}
700
701fn try_source_subpath(
702    ctx: &ResolveContext<'_>,
703    manifest: &PackageManifestInfo,
704    subpath: &Path,
705) -> Option<FileId> {
706    if subpath.as_os_str().is_empty()
707        && let Some(source) = manifest.package_json.source.as_deref()
708        && let Some(source) = source.strip_prefix("./")
709        && let Some(file_id) = lookup_internal_file_id(ctx, &manifest.root.join(source))
710    {
711        return Some(file_id);
712    }
713
714    for ext in SOURCE_EXTS {
715        let direct = if subpath.as_os_str().is_empty() {
716            manifest.root.join("src").join(format!("index.{ext}"))
717        } else {
718            manifest.root.join("src").join(subpath).with_extension(ext)
719        };
720        if let Some(file_id) = lookup_internal_file_id(ctx, &direct) {
721            return Some(file_id);
722        }
723
724        if !subpath.as_os_str().is_empty() {
725            let index = manifest
726                .root
727                .join("src")
728                .join(subpath)
729                .join(format!("index.{ext}"));
730            if let Some(file_id) = lookup_internal_file_id(ctx, &index) {
731                return Some(file_id);
732            }
733        }
734
735        if subpath.as_os_str().is_empty() {
736            let root_index = manifest.root.join(format!("index.{ext}"));
737            if let Some(file_id) = lookup_internal_file_id(ctx, &root_index) {
738                return Some(file_id);
739            }
740        }
741    }
742
743    None
744}
745
746fn lookup_internal_file_id(ctx: &ResolveContext<'_>, candidate: &Path) -> Option<FileId> {
747    if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
748        return Some(file_id);
749    }
750    if let Some(&file_id) = ctx.path_to_id.get(candidate) {
751        return Some(file_id);
752    }
753    #[cfg(not(miri))]
754    if let Ok(canonical) = dunce::canonicalize(candidate) {
755        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
756            return Some(file_id);
757        }
758        if let Some(fallback) = ctx.canonical_fallback
759            && let Some(file_id) = fallback.get(&canonical)
760        {
761            return Some(file_id);
762        }
763    }
764    None
765}
766
767/// Extract npm package name from a resolved path inside `node_modules`.
768///
769/// Given a path like `/project/node_modules/react/index.js`, returns `Some("react")`.
770/// Given a path like `/project/node_modules/@scope/pkg/dist/index.js`, returns `Some("@scope/pkg")`.
771/// Returns `None` if the path doesn't contain a `node_modules` segment.
772pub fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
773    let components: Vec<&str> = path
774        .components()
775        .filter_map(|c| match c {
776            std::path::Component::Normal(s) => s.to_str(),
777            _ => None,
778        })
779        .collect();
780
781    // Find the last "node_modules" component (handles nested node_modules)
782    let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
783
784    let after = &components[nm_idx + 1..];
785    if after.is_empty() {
786        return None;
787    }
788
789    if after[0].starts_with('@') {
790        // Scoped package: @scope/pkg
791        if after.len() >= 2 {
792            Some(format!("{}/{}", after[0], after[1]))
793        } else {
794            Some(after[0].to_string())
795        }
796    } else {
797        Some(after[0].to_string())
798    }
799}
800
801/// Try to map a pnpm virtual store path back to a workspace source file.
802///
803/// When pnpm uses injected dependencies or certain linking strategies, canonical
804/// paths go through `.pnpm`:
805///   `/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/index.js`
806///
807/// This function detects such paths, extracts the package name, checks if it
808/// matches a workspace package, and tries to find the source file in that workspace.
809pub(super) fn try_pnpm_workspace_fallback(
810    path: &Path,
811    path_to_id: &FxHashMap<&Path, FileId>,
812    workspace_roots: &FxHashMap<&str, &Path>,
813) -> Option<FileId> {
814    // Only relevant for paths containing .pnpm
815    let components: Vec<&str> = path
816        .components()
817        .filter_map(|c| match c {
818            std::path::Component::Normal(s) => s.to_str(),
819            _ => None,
820        })
821        .collect();
822
823    // Find .pnpm component
824    let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
825
826    // After .pnpm, find the inner node_modules (the actual package location)
827    // Structure: .pnpm/<name>@<version>/node_modules/<package>/...
828    let after_pnpm = &components[pnpm_idx + 1..];
829
830    // Find "node_modules" inside the .pnpm directory
831    let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
832    let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
833
834    if after_inner_nm.is_empty() {
835        return None;
836    }
837
838    // Extract package name (handle scoped packages)
839    let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
840        if after_inner_nm.len() >= 2 {
841            (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
842        } else {
843            return None;
844        }
845    } else {
846        (after_inner_nm[0].to_string(), 1)
847    };
848
849    // Check if this package is a workspace package
850    let ws_root = workspace_roots.get(pkg_name.as_str())?;
851
852    // Get the relative path within the package (after the package name components)
853    let relative_parts = &after_inner_nm[pkg_name_components..];
854    if relative_parts.is_empty() {
855        return None;
856    }
857
858    let relative_path: PathBuf = relative_parts.iter().collect();
859
860    // Try direct file lookup in workspace root
861    let direct = ws_root.join(&relative_path);
862    if let Some(&file_id) = path_to_id.get(direct.as_path()) {
863        return Some(file_id);
864    }
865
866    // Try source fallback (dist/ → src/ etc.) within the workspace
867    try_source_fallback(&direct, path_to_id)
868}
869
870/// Try to resolve a bare specifier as a workspace package reference.
871///
872/// When the specifier's package name matches a workspace package, resolve the
873/// subpath against that package's root directory directly instead of going
874/// through `node_modules`. Covers two cases:
875///
876/// 1. **Self-referencing package imports**: Node.js v12+ lets a package import
877///    itself via its own name (`import { X } from '@org/pkg/subentry'` from
878///    inside `@org/pkg`). Angular libraries built with `ng-packagr` rely on
879///    this to declare secondary entry points.
880/// 2. **Cross-workspace imports without `node_modules` symlinks**: monorepos
881///    that have not been installed yet, or bundlers that bypass `node_modules`
882///    entirely, still need to resolve `@org/other-pkg/sub` to the sibling
883///    workspace's source file.
884///
885/// Strategy: prefer a matching package `exports` target when the manifest has
886/// one, then strip the package name prefix and resolve the remainder as a
887/// relative path from inside the package root. The manifest branch covers
888/// source-only workspaces whose `exports` point at missing `dist` output.
889///
890/// See issues #106 and #641.
891pub(super) fn try_workspace_package_fallback(
892    ctx: &ResolveContext<'_>,
893    specifier: &str,
894) -> Option<ResolveResult> {
895    // Must look like a bare package specifier to avoid matching `./button`, etc.
896    if !super::path_info::is_bare_specifier(specifier) {
897        return None;
898    }
899    let pkg_name = super::path_info::extract_package_name(specifier);
900
901    // Remainder after the package name. Empty for `@org/pkg`, `"button"` for
902    // `@org/pkg/button`, `"internal/base"` for `@org/pkg/internal/base`.
903    let subpath = specifier
904        .strip_prefix(pkg_name.as_str())
905        .and_then(|s| s.strip_prefix('/'))
906        .unwrap_or("");
907    let source_subpath = PathBuf::from(subpath);
908
909    if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
910        let export_key = if subpath.is_empty() {
911            ".".to_string()
912        } else {
913            format!("./{subpath}")
914        };
915        if let Some(exports) = manifest.package_json.exports.as_ref() {
916            match package_map_target(exports, &export_key, ctx.condition_names) {
917                PackageMapTarget::Target(target) => {
918                    if let Some(file_id) = resolve_package_map_target(
919                        ctx,
920                        manifest,
921                        &target,
922                        Some(source_subpath.as_path()),
923                    ) {
924                        return Some(ResolveResult::InternalPackageModule {
925                            file_id,
926                            package_name: pkg_name,
927                        });
928                    }
929                }
930                PackageMapTarget::NoMatch | PackageMapTarget::Blocked => return None,
931            }
932        }
933    }
934
935    let (ws_root, package_name) =
936        if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
937            (manifest.root.as_path(), pkg_name)
938        } else {
939            (*ctx.workspace_roots.get(pkg_name.as_str())?, pkg_name)
940        };
941
942    // Synthetic importer inside the workspace root so tsconfig discovery walks
943    // up from the correct directory and relative specifiers anchor there.
944    let root_file = ws_root.join("__fallow_ws_self_resolve__");
945    let rel_spec = if subpath.is_empty() {
946        "./".to_string()
947    } else {
948        format!("./{subpath}")
949    };
950
951    let resolved = ctx.resolver.resolve_file(&root_file, &rel_spec).ok()?;
952    let resolved_path = resolved.path();
953
954    if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
955        return Some(ResolveResult::InternalPackageModule {
956            file_id,
957            package_name,
958        });
959    }
960    if let Ok(canonical) = dunce::canonicalize(resolved_path) {
961        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
962            return Some(ResolveResult::InternalPackageModule {
963                file_id,
964                package_name,
965            });
966        }
967        if let Some(fallback) = ctx.canonical_fallback
968            && let Some(file_id) = fallback.get(&canonical)
969        {
970            return Some(ResolveResult::InternalPackageModule {
971                file_id,
972                package_name,
973            });
974        }
975        if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
976            return Some(ResolveResult::InternalPackageModule {
977                file_id,
978                package_name,
979            });
980        }
981    }
982    None
983}
984
985/// Convert a `DynamicImportPattern` to a glob string for file matching.
986pub(super) fn make_glob_from_pattern(
987    pattern: &fallow_types::extract::DynamicImportPattern,
988) -> String {
989    // If the prefix already contains glob characters (from import.meta.glob), use as-is
990    if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
991        return pattern.prefix.clone();
992    }
993    pattern.suffix.as_ref().map_or_else(
994        || format!("{}*", pattern.prefix),
995        |suffix| format!("{}*{}", pattern.prefix, suffix),
996    )
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002    use rustc_hash::FxHashSet;
1003
1004    #[test]
1005    fn test_extract_package_name_from_node_modules_path_regular() {
1006        let path = PathBuf::from("/project/node_modules/react/index.js");
1007        assert_eq!(
1008            extract_package_name_from_node_modules_path(&path),
1009            Some("react".to_string())
1010        );
1011    }
1012
1013    #[test]
1014    fn test_extract_package_name_from_node_modules_path_scoped() {
1015        let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
1016        assert_eq!(
1017            extract_package_name_from_node_modules_path(&path),
1018            Some("@babel/core".to_string())
1019        );
1020    }
1021
1022    #[test]
1023    fn test_extract_package_name_from_node_modules_path_nested() {
1024        // Nested node_modules: should use the last (innermost) one
1025        let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
1026        assert_eq!(
1027            extract_package_name_from_node_modules_path(&path),
1028            Some("pkg-b".to_string())
1029        );
1030    }
1031
1032    #[test]
1033    fn test_extract_package_name_from_node_modules_path_deep_subpath() {
1034        let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
1035        assert_eq!(
1036            extract_package_name_from_node_modules_path(&path),
1037            Some("react-dom".to_string())
1038        );
1039    }
1040
1041    #[test]
1042    fn test_extract_package_name_from_node_modules_path_no_node_modules() {
1043        let path = PathBuf::from("/project/src/components/Button.tsx");
1044        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1045    }
1046
1047    #[test]
1048    fn test_extract_package_name_from_node_modules_path_just_node_modules() {
1049        let path = PathBuf::from("/project/node_modules");
1050        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1051    }
1052
1053    #[test]
1054    fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
1055        // Edge case: path ends at scope without package name
1056        let path = PathBuf::from("/project/node_modules/@scope");
1057        assert_eq!(
1058            extract_package_name_from_node_modules_path(&path),
1059            Some("@scope".to_string())
1060        );
1061    }
1062
1063    #[test]
1064    fn test_resolve_specifier_node_modules_returns_npm_package() {
1065        // When oxc_resolver resolves to a node_modules path that is NOT in path_to_id,
1066        // it should return NpmPackage instead of ExternalFile.
1067        // We can't easily test resolve_specifier directly without a real resolver,
1068        // but the extract_package_name_from_node_modules_path function covers the
1069        // core logic that was missing.
1070        let path =
1071            PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
1072        assert_eq!(
1073            extract_package_name_from_node_modules_path(&path),
1074            Some("styled-components".to_string())
1075        );
1076
1077        let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
1078        assert_eq!(
1079            extract_package_name_from_node_modules_path(&path),
1080            Some("next".to_string())
1081        );
1082    }
1083
1084    #[test]
1085    fn test_try_source_fallback_dist_to_src() {
1086        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1087        let mut path_to_id = FxHashMap::default();
1088        path_to_id.insert(src_path.as_path(), FileId(0));
1089
1090        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1091        assert_eq!(
1092            try_source_fallback(&dist_path, &path_to_id),
1093            Some(FileId(0)),
1094            "dist/utils.js should fall back to src/utils.ts"
1095        );
1096    }
1097
1098    #[test]
1099    fn test_try_source_fallback_build_to_src() {
1100        let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1101        let mut path_to_id = FxHashMap::default();
1102        path_to_id.insert(src_path.as_path(), FileId(1));
1103
1104        let build_path = PathBuf::from("/project/packages/core/build/index.js");
1105        assert_eq!(
1106            try_source_fallback(&build_path, &path_to_id),
1107            Some(FileId(1)),
1108            "build/index.js should fall back to src/index.tsx"
1109        );
1110    }
1111
1112    #[test]
1113    fn test_try_source_fallback_no_match() {
1114        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1115
1116        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1117        assert_eq!(
1118            try_source_fallback(&dist_path, &path_to_id),
1119            None,
1120            "should return None when no source file exists"
1121        );
1122    }
1123
1124    #[test]
1125    fn test_try_source_fallback_non_output_dir() {
1126        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1127        let mut path_to_id = FxHashMap::default();
1128        path_to_id.insert(src_path.as_path(), FileId(0));
1129
1130        // A path that's not in an output directory should not trigger fallback
1131        let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1132        assert_eq!(
1133            try_source_fallback(&normal_path, &path_to_id),
1134            None,
1135            "non-output directory path should not trigger fallback"
1136        );
1137    }
1138
1139    #[test]
1140    fn test_try_source_fallback_nested_path() {
1141        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1142        let mut path_to_id = FxHashMap::default();
1143        path_to_id.insert(src_path.as_path(), FileId(2));
1144
1145        let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1146        assert_eq!(
1147            try_source_fallback(&dist_path, &path_to_id),
1148            Some(FileId(2)),
1149            "nested dist path should fall back to nested src path"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_try_source_fallback_nested_dist_esm() {
1155        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1156        let mut path_to_id = FxHashMap::default();
1157        path_to_id.insert(src_path.as_path(), FileId(0));
1158
1159        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1160        assert_eq!(
1161            try_source_fallback(&dist_path, &path_to_id),
1162            Some(FileId(0)),
1163            "dist/esm/utils.mjs should fall back to src/utils.ts"
1164        );
1165    }
1166
1167    #[test]
1168    fn test_try_source_fallback_nested_build_cjs() {
1169        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1170        let mut path_to_id = FxHashMap::default();
1171        path_to_id.insert(src_path.as_path(), FileId(1));
1172
1173        let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1174        assert_eq!(
1175            try_source_fallback(&build_path, &path_to_id),
1176            Some(FileId(1)),
1177            "build/cjs/index.cjs should fall back to src/index.ts"
1178        );
1179    }
1180
1181    #[test]
1182    fn test_try_source_fallback_nested_dist_esm_deep_path() {
1183        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1184        let mut path_to_id = FxHashMap::default();
1185        path_to_id.insert(src_path.as_path(), FileId(2));
1186
1187        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1188        assert_eq!(
1189            try_source_fallback(&dist_path, &path_to_id),
1190            Some(FileId(2)),
1191            "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1192        );
1193    }
1194
1195    #[test]
1196    fn test_try_source_fallback_triple_nested_output_dirs() {
1197        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1198        let mut path_to_id = FxHashMap::default();
1199        path_to_id.insert(src_path.as_path(), FileId(0));
1200
1201        let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1202        assert_eq!(
1203            try_source_fallback(&dist_path, &path_to_id),
1204            Some(FileId(0)),
1205            "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1206        );
1207    }
1208
1209    #[test]
1210    fn test_try_source_fallback_parent_dir_named_build() {
1211        let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1212        let mut path_to_id = FxHashMap::default();
1213        path_to_id.insert(src_path.as_path(), FileId(0));
1214
1215        let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1216        assert_eq!(
1217            try_source_fallback(&dist_path, &path_to_id),
1218            Some(FileId(0)),
1219            "should resolve dist/ within project, not match parent 'build' dir"
1220        );
1221    }
1222
1223    #[test]
1224    fn package_map_exact_entry_beats_pattern_entry() {
1225        let map = serde_json::json!({
1226            "#nitro/runtime/task": "./dist/special/task.mjs",
1227            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1228        });
1229        assert_eq!(
1230            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1231            PackageMapTarget::Target("./dist/special/task.mjs".to_string())
1232        );
1233    }
1234
1235    #[test]
1236    fn package_map_wildcard_substitutes_capture() {
1237        let map = serde_json::json!({
1238            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1239        });
1240        assert_eq!(
1241            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1242            PackageMapTarget::Target("./dist/runtime/internal/task.mjs".to_string())
1243        );
1244    }
1245
1246    #[test]
1247    fn package_map_exact_entry_with_no_target_blocks_pattern_entry() {
1248        let map = serde_json::json!({
1249            "#nitro/runtime/task": null,
1250            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1251        });
1252        assert_eq!(
1253            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1254            PackageMapTarget::Blocked
1255        );
1256    }
1257
1258    #[test]
1259    fn package_map_best_pattern_with_no_target_blocks_broader_pattern() {
1260        let map = serde_json::json!({
1261            "#nitro/runtime/internal/*": null,
1262            "#nitro/runtime/*": "./dist/runtime/*.mjs"
1263        });
1264        assert_eq!(
1265            package_map_target(&map, "#nitro/runtime/internal/task", &conditions()),
1266            PackageMapTarget::Blocked
1267        );
1268    }
1269
1270    #[test]
1271    fn package_map_unmatched_subpath_is_not_a_target() {
1272        let map = serde_json::json!({
1273            "./query": "./dist/query/index.js"
1274        });
1275        assert_eq!(
1276            package_map_target(&map, "./private", &conditions()),
1277            PackageMapTarget::NoMatch
1278        );
1279    }
1280
1281    #[test]
1282    fn package_map_nested_conditions_follow_manifest_order() {
1283        let map = serde_json::json!({
1284            "./query/react": {
1285                "types": "./dist/query/react/index.d.ts",
1286                "import": {
1287                    "development": "./src/query/react/index.ts",
1288                    "default": "./dist/query/react/index.js"
1289                },
1290                "default": "./dist/query/react/index.cjs"
1291            }
1292        });
1293        assert_eq!(
1294            package_map_target(&map, "./query/react", &conditions()),
1295            PackageMapTarget::Target("./dist/query/react/index.d.ts".to_string())
1296        );
1297    }
1298
1299    #[test]
1300    fn package_map_import_before_types_selects_runtime_branch() {
1301        let map = serde_json::json!({
1302            ".": {
1303                "import": "./dist/index.js",
1304                "types": "./dist/index.d.ts"
1305            }
1306        });
1307        assert_eq!(
1308            package_map_target(&map, ".", &conditions()),
1309            PackageMapTarget::Target("./dist/index.js".to_string())
1310        );
1311    }
1312
1313    #[test]
1314    fn package_map_condition_order_follows_manifest_order() {
1315        let map = serde_json::json!({
1316            ".": {
1317                "node": "./dist/node.js",
1318                "import": "./dist/index.js"
1319            }
1320        });
1321        assert_eq!(
1322            package_map_target(&map, ".", &conditions()),
1323            PackageMapTarget::Target("./dist/node.js".to_string())
1324        );
1325    }
1326
1327    #[test]
1328    fn package_map_unsupported_shapes_are_skipped() {
1329        let map = serde_json::json!({
1330            "#array": ["./dist/array.js"],
1331            "#null": null,
1332            "#false": false
1333        });
1334        assert_eq!(
1335            package_map_target(&map, "#array", &conditions()),
1336            PackageMapTarget::Blocked
1337        );
1338        assert_eq!(
1339            package_map_target(&map, "#null", &conditions()),
1340            PackageMapTarget::Blocked
1341        );
1342        assert_eq!(
1343            package_map_target(&map, "#false", &conditions()),
1344            PackageMapTarget::Blocked
1345        );
1346    }
1347
1348    #[test]
1349    fn package_map_non_relative_target_does_not_trigger_source_fallback() {
1350        let root = PathBuf::from("/project");
1351        let manifest = PackageManifestInfo {
1352            root: root.clone(),
1353            canonical_root: root,
1354            name: Some("pkg".to_string()),
1355            package_json: fallow_config::PackageJson::default(),
1356        };
1357        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1358        let raw_path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1359        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1360        let condition_names = conditions();
1361        let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
1362        let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
1363        let ctx = ResolveContext {
1364            resolver: &resolver,
1365            style_resolver: &resolver,
1366            extensions: &[],
1367            path_to_id: &path_to_id,
1368            raw_path_to_id: &raw_path_to_id,
1369            workspace_roots: &workspace_roots,
1370            package_manifests: std::slice::from_ref(&manifest),
1371            condition_names: &condition_names,
1372            path_aliases: &[],
1373            scss_include_paths: &[],
1374            root: &manifest.root,
1375            canonical_fallback: None,
1376            tsconfig_warned: &tsconfig_warned,
1377        };
1378
1379        assert!(resolve_package_map_target(&ctx, &manifest, "lodash", None).is_none());
1380        assert!(resolve_package_map_target(&ctx, &manifest, "../dist/index.js", None).is_none());
1381    }
1382
1383    #[test]
1384    fn package_imports_fallback_supports_unnamed_packages() {
1385        let root = PathBuf::from("/project");
1386        let src_path = root.join("src/runtime/task.ts");
1387        let manifest = PackageManifestInfo {
1388            root: root.clone(),
1389            canonical_root: root.clone(),
1390            name: None,
1391            package_json: fallow_config::PackageJson {
1392                imports: Some(serde_json::json!({
1393                    "#runtime/*": "./dist/runtime/*.mjs"
1394                })),
1395                ..Default::default()
1396            },
1397        };
1398        let mut raw_path_to_id = FxHashMap::default();
1399        raw_path_to_id.insert(src_path.as_path(), FileId(7));
1400        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1401        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1402        let condition_names = conditions();
1403        let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
1404        let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
1405        let ctx = ResolveContext {
1406            resolver: &resolver,
1407            style_resolver: &resolver,
1408            extensions: &[],
1409            path_to_id: &path_to_id,
1410            raw_path_to_id: &raw_path_to_id,
1411            workspace_roots: &workspace_roots,
1412            package_manifests: std::slice::from_ref(&manifest),
1413            condition_names: &condition_names,
1414            path_aliases: &[],
1415            scss_include_paths: &[],
1416            root: &manifest.root,
1417            canonical_fallback: None,
1418            tsconfig_warned: &tsconfig_warned,
1419        };
1420
1421        let result =
1422            try_package_imports_fallback(&ctx, &root.join("src/index.ts"), "#runtime/task");
1423        assert!(matches!(
1424            result,
1425            Some(ResolveResult::InternalModule(FileId(7)))
1426        ));
1427    }
1428
1429    #[test]
1430    fn test_pnpm_store_path_extract_package_name() {
1431        // pnpm virtual store paths should correctly extract package name
1432        let path =
1433            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1434        assert_eq!(
1435            extract_package_name_from_node_modules_path(&path),
1436            Some("react".to_string())
1437        );
1438    }
1439
1440    #[test]
1441    fn test_pnpm_store_path_scoped_package() {
1442        let path = PathBuf::from(
1443            "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1444        );
1445        assert_eq!(
1446            extract_package_name_from_node_modules_path(&path),
1447            Some("@babel/core".to_string())
1448        );
1449    }
1450
1451    fn conditions() -> Vec<String> {
1452        vec![
1453            "development".to_string(),
1454            "import".to_string(),
1455            "require".to_string(),
1456            "default".to_string(),
1457            "types".to_string(),
1458            "node".to_string(),
1459        ]
1460    }
1461
1462    #[test]
1463    fn test_pnpm_store_path_with_peer_deps() {
1464        let path = PathBuf::from(
1465            "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
1466        );
1467        assert_eq!(
1468            extract_package_name_from_node_modules_path(&path),
1469            Some("webpack".to_string())
1470        );
1471    }
1472
1473    #[test]
1474    fn test_try_pnpm_workspace_fallback_dist_to_src() {
1475        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1476        let mut path_to_id = FxHashMap::default();
1477        path_to_id.insert(src_path.as_path(), FileId(0));
1478
1479        let mut workspace_roots = FxHashMap::default();
1480        let ws_root = PathBuf::from("/project/packages/ui");
1481        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1482
1483        // pnpm virtual store path with dist/ output
1484        let pnpm_path = PathBuf::from(
1485            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
1486        );
1487        assert_eq!(
1488            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1489            Some(FileId(0)),
1490            ".pnpm workspace path should fall back to src/utils.ts"
1491        );
1492    }
1493
1494    #[test]
1495    fn test_try_pnpm_workspace_fallback_direct_source() {
1496        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1497        let mut path_to_id = FxHashMap::default();
1498        path_to_id.insert(src_path.as_path(), FileId(1));
1499
1500        let mut workspace_roots = FxHashMap::default();
1501        let ws_root = PathBuf::from("/project/packages/core");
1502        workspace_roots.insert("@myorg/core", ws_root.as_path());
1503
1504        // pnpm path pointing directly to src/
1505        let pnpm_path = PathBuf::from(
1506            "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
1507        );
1508        assert_eq!(
1509            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1510            Some(FileId(1)),
1511            ".pnpm workspace path with src/ should resolve directly"
1512        );
1513    }
1514
1515    #[test]
1516    fn test_try_pnpm_workspace_fallback_non_workspace_package() {
1517        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1518
1519        let mut workspace_roots = FxHashMap::default();
1520        let ws_root = PathBuf::from("/project/packages/ui");
1521        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1522
1523        // External package (not a workspace) — should return None
1524        let pnpm_path =
1525            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1526        assert_eq!(
1527            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1528            None,
1529            "non-workspace package in .pnpm should return None"
1530        );
1531    }
1532
1533    #[test]
1534    fn test_try_pnpm_workspace_fallback_unscoped_package() {
1535        let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
1536        let mut path_to_id = FxHashMap::default();
1537        path_to_id.insert(src_path.as_path(), FileId(2));
1538
1539        let mut workspace_roots = FxHashMap::default();
1540        let ws_root = PathBuf::from("/project/packages/utils");
1541        workspace_roots.insert("my-utils", ws_root.as_path());
1542
1543        // Unscoped workspace package in pnpm store
1544        let pnpm_path = PathBuf::from(
1545            "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
1546        );
1547        assert_eq!(
1548            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1549            Some(FileId(2)),
1550            "unscoped workspace package in .pnpm should resolve"
1551        );
1552    }
1553
1554    #[test]
1555    fn test_try_pnpm_workspace_fallback_nested_path() {
1556        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1557        let mut path_to_id = FxHashMap::default();
1558        path_to_id.insert(src_path.as_path(), FileId(3));
1559
1560        let mut workspace_roots = FxHashMap::default();
1561        let ws_root = PathBuf::from("/project/packages/ui");
1562        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1563
1564        // Nested path within the package
1565        let pnpm_path = PathBuf::from(
1566            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1567        );
1568        assert_eq!(
1569            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1570            Some(FileId(3)),
1571            "nested .pnpm workspace path should resolve through source fallback"
1572        );
1573    }
1574
1575    #[test]
1576    fn test_try_pnpm_workspace_fallback_no_pnpm() {
1577        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1578        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1579
1580        // Regular path without .pnpm — should return None immediately
1581        let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1582        assert_eq!(
1583            try_pnpm_workspace_fallback(&regular_path, &path_to_id, &workspace_roots),
1584            None,
1585        );
1586    }
1587
1588    #[test]
1589    fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1590        let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1591        let mut path_to_id = FxHashMap::default();
1592        path_to_id.insert(src_path.as_path(), FileId(4));
1593
1594        let mut workspace_roots = FxHashMap::default();
1595        let ws_root = PathBuf::from("/project/packages/ui");
1596        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1597
1598        // pnpm path with peer dependency suffix
1599        let pnpm_path = PathBuf::from(
1600            "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1601        );
1602        assert_eq!(
1603            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1604            Some(FileId(4)),
1605            ".pnpm path with peer dep suffix should still resolve"
1606        );
1607    }
1608
1609    // ── make_glob_from_pattern ───────────────────────────────────────
1610
1611    #[test]
1612    fn make_glob_prefix_only_no_suffix() {
1613        let pattern = fallow_types::extract::DynamicImportPattern {
1614            prefix: "./locales/".to_string(),
1615            suffix: None,
1616            span: oxc_span::Span::default(),
1617        };
1618        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*");
1619    }
1620
1621    #[test]
1622    fn make_glob_prefix_with_suffix() {
1623        let pattern = fallow_types::extract::DynamicImportPattern {
1624            prefix: "./locales/".to_string(),
1625            suffix: Some(".json".to_string()),
1626            span: oxc_span::Span::default(),
1627        };
1628        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1629    }
1630
1631    #[test]
1632    fn make_glob_passthrough_star() {
1633        // Prefix already contains glob characters — use as-is
1634        let pattern = fallow_types::extract::DynamicImportPattern {
1635            prefix: "./pages/**/*.tsx".to_string(),
1636            suffix: None,
1637            span: oxc_span::Span::default(),
1638        };
1639        assert_eq!(make_glob_from_pattern(&pattern), "./pages/**/*.tsx");
1640    }
1641
1642    #[test]
1643    fn make_glob_passthrough_brace() {
1644        let pattern = fallow_types::extract::DynamicImportPattern {
1645            prefix: "./i18n/{en,de,fr}.json".to_string(),
1646            suffix: None,
1647            span: oxc_span::Span::default(),
1648        };
1649        assert_eq!(make_glob_from_pattern(&pattern), "./i18n/{en,de,fr}.json");
1650    }
1651
1652    #[test]
1653    fn make_glob_empty_prefix_no_suffix() {
1654        let pattern = fallow_types::extract::DynamicImportPattern {
1655            prefix: String::new(),
1656            suffix: None,
1657            span: oxc_span::Span::default(),
1658        };
1659        assert_eq!(make_glob_from_pattern(&pattern), "*");
1660    }
1661
1662    #[test]
1663    fn make_glob_empty_prefix_with_suffix() {
1664        let pattern = fallow_types::extract::DynamicImportPattern {
1665            prefix: String::new(),
1666            suffix: Some(".ts".to_string()),
1667            span: oxc_span::Span::default(),
1668        };
1669        assert_eq!(make_glob_from_pattern(&pattern), "*.ts");
1670    }
1671
1672    // ── make_glob_from_pattern: template literal patterns ──────────
1673
1674    #[test]
1675    fn make_glob_template_literal_prefix_only() {
1676        // `./pages/${page}` extracts prefix="./pages/", suffix=None
1677        let pattern = fallow_types::extract::DynamicImportPattern {
1678            prefix: "./pages/".to_string(),
1679            suffix: None,
1680            span: oxc_span::Span::default(),
1681        };
1682        assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1683    }
1684
1685    #[test]
1686    fn make_glob_template_literal_with_extension_suffix() {
1687        // `./locales/${lang}.json` extracts prefix="./locales/", suffix=".json"
1688        let pattern = fallow_types::extract::DynamicImportPattern {
1689            prefix: "./locales/".to_string(),
1690            suffix: Some(".json".to_string()),
1691            span: oxc_span::Span::default(),
1692        };
1693        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1694    }
1695
1696    #[test]
1697    fn make_glob_template_literal_deep_prefix() {
1698        // `./modules/${area}/components/${name}.tsx`
1699        // Extractor captures prefix="./modules/", suffix=None (only first dynamic part)
1700        let pattern = fallow_types::extract::DynamicImportPattern {
1701            prefix: "./modules/".to_string(),
1702            suffix: None,
1703            span: oxc_span::Span::default(),
1704        };
1705        assert_eq!(make_glob_from_pattern(&pattern), "./modules/*");
1706    }
1707
1708    #[test]
1709    fn make_glob_string_concat_prefix() {
1710        // `'./pages/' + name` extracts prefix="./pages/", suffix=None
1711        let pattern = fallow_types::extract::DynamicImportPattern {
1712            prefix: "./pages/".to_string(),
1713            suffix: None,
1714            span: oxc_span::Span::default(),
1715        };
1716        assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1717    }
1718
1719    #[test]
1720    fn make_glob_string_concat_with_extension() {
1721        // `'./views/' + name + '.vue'` extracts prefix="./views/", suffix=".vue"
1722        let pattern = fallow_types::extract::DynamicImportPattern {
1723            prefix: "./views/".to_string(),
1724            suffix: Some(".vue".to_string()),
1725            span: oxc_span::Span::default(),
1726        };
1727        assert_eq!(make_glob_from_pattern(&pattern), "./views/*.vue");
1728    }
1729
1730    // ── make_glob_from_pattern: import.meta.glob ──────────────────
1731
1732    #[test]
1733    fn make_glob_import_meta_glob_recursive() {
1734        // import.meta.glob('./components/**/*.vue')
1735        let pattern = fallow_types::extract::DynamicImportPattern {
1736            prefix: "./components/**/*.vue".to_string(),
1737            suffix: None,
1738            span: oxc_span::Span::default(),
1739        };
1740        assert_eq!(
1741            make_glob_from_pattern(&pattern),
1742            "./components/**/*.vue",
1743            "import.meta.glob patterns with * should pass through as-is"
1744        );
1745    }
1746
1747    #[test]
1748    fn make_glob_import_meta_glob_brace_expansion() {
1749        // import.meta.glob('./plugins/{auth,analytics}.ts')
1750        let pattern = fallow_types::extract::DynamicImportPattern {
1751            prefix: "./plugins/{auth,analytics}.ts".to_string(),
1752            suffix: None,
1753            span: oxc_span::Span::default(),
1754        };
1755        assert_eq!(
1756            make_glob_from_pattern(&pattern),
1757            "./plugins/{auth,analytics}.ts",
1758            "import.meta.glob patterns with braces should pass through as-is"
1759        );
1760    }
1761
1762    #[test]
1763    fn make_glob_import_meta_glob_star_with_brace() {
1764        // import.meta.glob('./routes/**/*.{ts,tsx}')
1765        let pattern = fallow_types::extract::DynamicImportPattern {
1766            prefix: "./routes/**/*.{ts,tsx}".to_string(),
1767            suffix: None,
1768            span: oxc_span::Span::default(),
1769        };
1770        assert_eq!(
1771            make_glob_from_pattern(&pattern),
1772            "./routes/**/*.{ts,tsx}",
1773            "combined * and brace patterns should pass through"
1774        );
1775    }
1776
1777    #[test]
1778    fn make_glob_import_meta_glob_ignores_suffix_when_star_present() {
1779        // Edge case: prefix contains *, suffix is provided (unlikely but defensive)
1780        let pattern = fallow_types::extract::DynamicImportPattern {
1781            prefix: "./*.ts".to_string(),
1782            suffix: Some(".extra".to_string()),
1783            span: oxc_span::Span::default(),
1784        };
1785        assert_eq!(
1786            make_glob_from_pattern(&pattern),
1787            "./*.ts",
1788            "when prefix has glob chars, suffix is ignored (prefix used as-is)"
1789        );
1790    }
1791
1792    // ── make_glob_from_pattern: edge cases ────────────────────────
1793
1794    #[test]
1795    fn make_glob_single_dot_prefix() {
1796        let pattern = fallow_types::extract::DynamicImportPattern {
1797            prefix: "./".to_string(),
1798            suffix: None,
1799            span: oxc_span::Span::default(),
1800        };
1801        assert_eq!(make_glob_from_pattern(&pattern), "./*");
1802    }
1803
1804    #[test]
1805    fn make_glob_prefix_without_trailing_slash() {
1806        // `'./config' + ext` -> prefix="./config", suffix might be extension
1807        let pattern = fallow_types::extract::DynamicImportPattern {
1808            prefix: "./config".to_string(),
1809            suffix: None,
1810            span: oxc_span::Span::default(),
1811        };
1812        assert_eq!(make_glob_from_pattern(&pattern), "./config*");
1813    }
1814
1815    #[test]
1816    fn make_glob_prefix_with_dotdot() {
1817        let pattern = fallow_types::extract::DynamicImportPattern {
1818            prefix: "../shared/".to_string(),
1819            suffix: Some(".ts".to_string()),
1820            span: oxc_span::Span::default(),
1821        };
1822        assert_eq!(make_glob_from_pattern(&pattern), "../shared/*.ts");
1823    }
1824
1825    // ── extract_package_name: additional edge cases ───────────────
1826
1827    #[test]
1828    fn test_extract_package_name_with_pnpm_plus_encoded_scope() {
1829        // pnpm encodes @scope/pkg as @scope+pkg in store path
1830        // but the inner node_modules still uses the real scope
1831        let path = PathBuf::from(
1832            "/project/node_modules/.pnpm/@mui+material@5.15.0/node_modules/@mui/material/index.js",
1833        );
1834        assert_eq!(
1835            extract_package_name_from_node_modules_path(&path),
1836            Some("@mui/material".to_string())
1837        );
1838    }
1839
1840    #[test]
1841    fn test_extract_package_name_windows_style_path() {
1842        // Windows-style paths should still work since we filter for Normal components
1843        let path = PathBuf::from("/project/node_modules/typescript/lib/tsc.js");
1844        assert_eq!(
1845            extract_package_name_from_node_modules_path(&path),
1846            Some("typescript".to_string())
1847        );
1848    }
1849
1850    // ── try_source_fallback: additional output dir patterns ───────
1851
1852    #[test]
1853    fn test_try_source_fallback_out_dir() {
1854        let src_path = PathBuf::from("/project/packages/api/src/handler.ts");
1855        let mut path_to_id = FxHashMap::default();
1856        path_to_id.insert(src_path.as_path(), FileId(5));
1857
1858        let out_path = PathBuf::from("/project/packages/api/out/handler.js");
1859        assert_eq!(
1860            try_source_fallback(&out_path, &path_to_id),
1861            Some(FileId(5)),
1862            "out/handler.js should fall back to src/handler.ts"
1863        );
1864    }
1865
1866    #[test]
1867    fn test_try_source_fallback_mts_extension() {
1868        let src_path = PathBuf::from("/project/packages/lib/src/utils.mts");
1869        let mut path_to_id = FxHashMap::default();
1870        path_to_id.insert(src_path.as_path(), FileId(6));
1871
1872        let dist_path = PathBuf::from("/project/packages/lib/dist/utils.mjs");
1873        assert_eq!(
1874            try_source_fallback(&dist_path, &path_to_id),
1875            Some(FileId(6)),
1876            "dist/utils.mjs should fall back to src/utils.mts"
1877        );
1878    }
1879
1880    #[test]
1881    fn test_try_source_fallback_cts_extension() {
1882        let src_path = PathBuf::from("/project/packages/lib/src/config.cts");
1883        let mut path_to_id = FxHashMap::default();
1884        path_to_id.insert(src_path.as_path(), FileId(7));
1885
1886        let dist_path = PathBuf::from("/project/packages/lib/dist/config.cjs");
1887        assert_eq!(
1888            try_source_fallback(&dist_path, &path_to_id),
1889            Some(FileId(7)),
1890            "dist/config.cjs should fall back to src/config.cts"
1891        );
1892    }
1893
1894    #[test]
1895    fn test_try_source_fallback_jsx_extension() {
1896        let src_path = PathBuf::from("/project/packages/ui/src/App.jsx");
1897        let mut path_to_id = FxHashMap::default();
1898        path_to_id.insert(src_path.as_path(), FileId(8));
1899
1900        let build_path = PathBuf::from("/project/packages/ui/build/App.js");
1901        assert_eq!(
1902            try_source_fallback(&build_path, &path_to_id),
1903            Some(FileId(8)),
1904            "build/App.js should fall back to src/App.jsx"
1905        );
1906    }
1907
1908    #[test]
1909    fn test_try_source_fallback_no_file_stem() {
1910        // Path with no filename at all should return None gracefully
1911        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1912        let dist_path = PathBuf::from("/project/packages/ui/dist/");
1913        assert_eq!(
1914            try_source_fallback(&dist_path, &path_to_id),
1915            None,
1916            "directory path with no file should return None"
1917        );
1918    }
1919
1920    #[test]
1921    fn test_try_source_fallback_esm_subdir() {
1922        // esm is an output directory, so dist/esm -> src
1923        let src_path = PathBuf::from("/project/lib/src/index.ts");
1924        let mut path_to_id = FxHashMap::default();
1925        path_to_id.insert(src_path.as_path(), FileId(10));
1926
1927        let dist_path = PathBuf::from("/project/lib/esm/index.mjs");
1928        assert_eq!(
1929            try_source_fallback(&dist_path, &path_to_id),
1930            Some(FileId(10)),
1931            "standalone esm/ directory should fall back to src/"
1932        );
1933    }
1934
1935    #[test]
1936    fn test_try_source_fallback_cjs_subdir() {
1937        let src_path = PathBuf::from("/project/lib/src/index.ts");
1938        let mut path_to_id = FxHashMap::default();
1939        path_to_id.insert(src_path.as_path(), FileId(11));
1940
1941        let cjs_path = PathBuf::from("/project/lib/cjs/index.cjs");
1942        assert_eq!(
1943            try_source_fallback(&cjs_path, &path_to_id),
1944            Some(FileId(11)),
1945            "standalone cjs/ directory should fall back to src/"
1946        );
1947    }
1948
1949    // ── try_pnpm_workspace_fallback: edge cases ──────────────────
1950
1951    #[test]
1952    fn test_try_pnpm_workspace_fallback_empty_after_pnpm() {
1953        // Path that has .pnpm but nothing after the inner node_modules
1954        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1955        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1956
1957        let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/node_modules");
1958        assert_eq!(
1959            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1960            None,
1961            "path ending at node_modules with nothing after should return None"
1962        );
1963    }
1964
1965    #[test]
1966    fn test_try_pnpm_workspace_fallback_scoped_package_only_scope() {
1967        // Path has .pnpm/inner-node_modules/@scope but no package name after scope
1968        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1969        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1970
1971        let pnpm_path =
1972            PathBuf::from("/project/node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope");
1973        assert_eq!(
1974            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1975            None,
1976            "scoped package without full name and no matching workspace should return None"
1977        );
1978    }
1979
1980    #[test]
1981    fn test_try_pnpm_workspace_fallback_no_inner_node_modules() {
1982        // Path has .pnpm but no inner node_modules
1983        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1984        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1985
1986        let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/dist/index.js");
1987        assert_eq!(
1988            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1989            None,
1990            "path without inner node_modules after .pnpm should return None"
1991        );
1992    }
1993
1994    #[test]
1995    fn test_try_pnpm_workspace_fallback_package_without_relative_path() {
1996        // Path ends right at the package name, no file path after it
1997        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1998        let mut workspace_roots = FxHashMap::default();
1999        let ws_root = PathBuf::from("/project/packages/ui");
2000        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2001
2002        let pnpm_path =
2003            PathBuf::from("/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui");
2004        assert_eq!(
2005            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2006            None,
2007            "path ending at package name with no relative file should return None"
2008        );
2009    }
2010
2011    #[test]
2012    fn test_try_pnpm_workspace_fallback_nested_dist_esm() {
2013        let src_path = PathBuf::from("/project/packages/ui/src/Button.ts");
2014        let mut path_to_id = FxHashMap::default();
2015        path_to_id.insert(src_path.as_path(), FileId(10));
2016
2017        let mut workspace_roots = FxHashMap::default();
2018        let ws_root = PathBuf::from("/project/packages/ui");
2019        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2020
2021        // Nested output dirs within pnpm workspace path
2022        let pnpm_path = PathBuf::from(
2023            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/esm/Button.mjs",
2024        );
2025        assert_eq!(
2026            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2027            Some(FileId(10)),
2028            "pnpm path with nested dist/esm should resolve through source fallback"
2029        );
2030    }
2031}