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