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 strip the package name prefix and resolve the remainder as a
958/// relative path from inside the package root. The manifest branch covers
959/// source-only workspaces whose `exports` point at missing `dist` output.
960///
961/// See issues #106 and #641.
962pub(super) fn try_workspace_package_fallback(
963    ctx: &ResolveContext<'_>,
964    specifier: &str,
965) -> Option<ResolveResult> {
966    // Must look like a bare package specifier to avoid matching `./button`, etc.
967    if !super::path_info::is_bare_specifier(specifier) {
968        return None;
969    }
970    let pkg_name = super::path_info::extract_package_name(specifier);
971
972    // Remainder after the package name. Empty for `@org/pkg`, `"button"` for
973    // `@org/pkg/button`, `"internal/base"` for `@org/pkg/internal/base`.
974    let subpath = specifier
975        .strip_prefix(pkg_name.as_str())
976        .and_then(|s| s.strip_prefix('/'))
977        .unwrap_or("");
978    let source_subpath = PathBuf::from(subpath);
979
980    if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
981        let export_key = if subpath.is_empty() {
982            ".".to_string()
983        } else {
984            format!("./{subpath}")
985        };
986        if let Some(exports) = manifest.package_json.exports.as_ref() {
987            match package_map_target(exports, &export_key, ctx.condition_names) {
988                PackageMapTarget::Targets(targets) => {
989                    if let Some(file_id) = resolve_package_map_targets(
990                        ctx,
991                        manifest,
992                        &targets,
993                        Some(source_subpath.as_path()),
994                    ) {
995                        return Some(ResolveResult::InternalPackageModule {
996                            file_id,
997                            package_name: pkg_name,
998                        });
999                    }
1000                }
1001                PackageMapTarget::NoMatch | PackageMapTarget::Blocked => return None,
1002            }
1003        }
1004    }
1005
1006    let (ws_root, package_name) =
1007        if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
1008            (manifest.root.as_path(), pkg_name)
1009        } else {
1010            (*ctx.workspace_roots.get(pkg_name.as_str())?, pkg_name)
1011        };
1012
1013    // Synthetic importer inside the workspace root so tsconfig discovery walks
1014    // up from the correct directory and relative specifiers anchor there.
1015    let root_file = ws_root.join("__fallow_ws_self_resolve__");
1016    let rel_spec = if subpath.is_empty() {
1017        "./".to_string()
1018    } else {
1019        format!("./{subpath}")
1020    };
1021
1022    let resolved = ctx.resolver.resolve_file(&root_file, &rel_spec).ok()?;
1023    let resolved_path = resolved.path();
1024
1025    if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
1026        return Some(ResolveResult::InternalPackageModule {
1027            file_id,
1028            package_name,
1029        });
1030    }
1031    if let Ok(canonical) = dunce::canonicalize(resolved_path) {
1032        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
1033            return Some(ResolveResult::InternalPackageModule {
1034                file_id,
1035                package_name,
1036            });
1037        }
1038        if let Some(fallback) = ctx.canonical_fallback
1039            && let Some(file_id) = fallback.get(&canonical)
1040        {
1041            return Some(ResolveResult::InternalPackageModule {
1042                file_id,
1043                package_name,
1044            });
1045        }
1046        if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
1047            return Some(ResolveResult::InternalPackageModule {
1048                file_id,
1049                package_name,
1050            });
1051        }
1052    }
1053    None
1054}
1055
1056/// Convert a `DynamicImportPattern` to a glob string for file matching.
1057pub(super) fn make_glob_from_pattern(
1058    pattern: &fallow_types::extract::DynamicImportPattern,
1059) -> String {
1060    // If the prefix already contains glob characters (from import.meta.glob), use as-is
1061    if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
1062        return pattern.prefix.clone();
1063    }
1064    pattern.suffix.as_ref().map_or_else(
1065        || format!("{}*", pattern.prefix),
1066        |suffix| format!("{}*{}", pattern.prefix, suffix),
1067    )
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073    use rustc_hash::FxHashSet;
1074
1075    fn with_package_map_ctx(
1076        root: PathBuf,
1077        name: Option<&str>,
1078        package_json: fallow_config::PackageJson,
1079        raw_files: &[(PathBuf, FileId)],
1080        f: impl FnOnce(&ResolveContext<'_>, &PackageManifestInfo, &Path),
1081    ) {
1082        let manifest = PackageManifestInfo {
1083            root: root.clone(),
1084            canonical_root: root,
1085            name: name.map(str::to_string),
1086            package_json,
1087        };
1088        let manifests = [manifest];
1089        let mut raw_path_to_id = FxHashMap::default();
1090        for (path, file_id) in raw_files {
1091            raw_path_to_id.insert(path.as_path(), *file_id);
1092        }
1093        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1094        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1095        let condition_names = conditions();
1096        let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
1097        let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
1098        let ctx = ResolveContext {
1099            resolver: &resolver,
1100            style_resolver: &resolver,
1101            extensions: &[],
1102            path_to_id: &path_to_id,
1103            raw_path_to_id: &raw_path_to_id,
1104            workspace_roots: &workspace_roots,
1105            package_manifests: &manifests,
1106            condition_names: &condition_names,
1107            path_aliases: &[],
1108            scss_include_paths: &[],
1109            root: &manifests[0].root,
1110            canonical_fallback: None,
1111            tsconfig_warned: &tsconfig_warned,
1112        };
1113
1114        f(&ctx, &manifests[0], &manifests[0].root);
1115    }
1116
1117    #[test]
1118    fn alias_match_remainder_exact_key() {
1119        // Exact-key alias (the whole specifier equals the key): empty remainder.
1120        assert_eq!(alias_match_remainder("vscode", "vscode"), Some(""));
1121        assert_eq!(alias_match_remainder("@scope/sdk", "@scope/sdk"), Some(""));
1122    }
1123
1124    #[test]
1125    fn alias_match_remainder_slash_continuation() {
1126        // Slash-delimited continuation matches for both bare and trailing-slash prefixes.
1127        assert_eq!(
1128            alias_match_remainder("@scope/sdk/sub", "@scope/sdk"),
1129            Some("/sub")
1130        );
1131        assert_eq!(alias_match_remainder("@/foo", "@/"), Some("foo"));
1132        assert_eq!(
1133            alias_match_remainder("~/components/x", "~/"),
1134            Some("components/x")
1135        );
1136        assert_eq!(alias_match_remainder("$lib/util", "$lib/"), Some("util"));
1137    }
1138
1139    #[test]
1140    fn alias_match_remainder_rejects_prefix_collision() {
1141        // The collision class: an exact-key alias must NOT capture a longer
1142        // package that merely shares the prefix without a path boundary.
1143        assert_eq!(
1144            alias_match_remainder("@scope/sdk-extra", "@scope/sdk"),
1145            None
1146        );
1147        assert_eq!(
1148            alias_match_remainder("vscode-languageserver", "vscode"),
1149            None
1150        );
1151        assert_eq!(alias_match_remainder("#shared-utils", "#shared"), None);
1152    }
1153
1154    #[test]
1155    fn alias_match_remainder_non_match() {
1156        assert_eq!(alias_match_remainder("react", "vscode"), None);
1157    }
1158
1159    #[test]
1160    fn test_extract_package_name_from_node_modules_path_regular() {
1161        let path = PathBuf::from("/project/node_modules/react/index.js");
1162        assert_eq!(
1163            extract_package_name_from_node_modules_path(&path),
1164            Some("react".to_string())
1165        );
1166    }
1167
1168    #[test]
1169    fn test_extract_package_name_from_node_modules_path_scoped() {
1170        let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
1171        assert_eq!(
1172            extract_package_name_from_node_modules_path(&path),
1173            Some("@babel/core".to_string())
1174        );
1175    }
1176
1177    #[test]
1178    fn test_extract_package_name_from_node_modules_path_nested() {
1179        // Nested node_modules: should use the last (innermost) one
1180        let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
1181        assert_eq!(
1182            extract_package_name_from_node_modules_path(&path),
1183            Some("pkg-b".to_string())
1184        );
1185    }
1186
1187    #[test]
1188    fn test_extract_package_name_from_node_modules_path_deep_subpath() {
1189        let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
1190        assert_eq!(
1191            extract_package_name_from_node_modules_path(&path),
1192            Some("react-dom".to_string())
1193        );
1194    }
1195
1196    #[test]
1197    fn test_extract_package_name_from_node_modules_path_no_node_modules() {
1198        let path = PathBuf::from("/project/src/components/Button.tsx");
1199        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1200    }
1201
1202    #[test]
1203    fn test_extract_package_name_from_node_modules_path_just_node_modules() {
1204        let path = PathBuf::from("/project/node_modules");
1205        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1206    }
1207
1208    #[test]
1209    fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
1210        // Edge case: path ends at scope without package name
1211        let path = PathBuf::from("/project/node_modules/@scope");
1212        assert_eq!(
1213            extract_package_name_from_node_modules_path(&path),
1214            Some("@scope".to_string())
1215        );
1216    }
1217
1218    #[test]
1219    fn test_resolve_specifier_node_modules_returns_npm_package() {
1220        // When oxc_resolver resolves to a node_modules path that is NOT in path_to_id,
1221        // it should return NpmPackage instead of ExternalFile.
1222        // We can't easily test resolve_specifier directly without a real resolver,
1223        // but the extract_package_name_from_node_modules_path function covers the
1224        // core logic that was missing.
1225        let path =
1226            PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
1227        assert_eq!(
1228            extract_package_name_from_node_modules_path(&path),
1229            Some("styled-components".to_string())
1230        );
1231
1232        let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
1233        assert_eq!(
1234            extract_package_name_from_node_modules_path(&path),
1235            Some("next".to_string())
1236        );
1237    }
1238
1239    #[test]
1240    fn test_try_source_fallback_dist_to_src() {
1241        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1242        let mut path_to_id = FxHashMap::default();
1243        path_to_id.insert(src_path.as_path(), FileId(0));
1244
1245        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1246        assert_eq!(
1247            try_source_fallback(&dist_path, &path_to_id),
1248            Some(FileId(0)),
1249            "dist/utils.js should fall back to src/utils.ts"
1250        );
1251    }
1252
1253    #[test]
1254    fn test_try_source_fallback_build_to_src() {
1255        let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1256        let mut path_to_id = FxHashMap::default();
1257        path_to_id.insert(src_path.as_path(), FileId(1));
1258
1259        let build_path = PathBuf::from("/project/packages/core/build/index.js");
1260        assert_eq!(
1261            try_source_fallback(&build_path, &path_to_id),
1262            Some(FileId(1)),
1263            "build/index.js should fall back to src/index.tsx"
1264        );
1265    }
1266
1267    #[test]
1268    fn test_try_source_fallback_no_match() {
1269        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1270
1271        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1272        assert_eq!(
1273            try_source_fallback(&dist_path, &path_to_id),
1274            None,
1275            "should return None when no source file exists"
1276        );
1277    }
1278
1279    #[test]
1280    fn test_try_source_fallback_non_output_dir() {
1281        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1282        let mut path_to_id = FxHashMap::default();
1283        path_to_id.insert(src_path.as_path(), FileId(0));
1284
1285        // A path that's not in an output directory should not trigger fallback
1286        let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1287        assert_eq!(
1288            try_source_fallback(&normal_path, &path_to_id),
1289            None,
1290            "non-output directory path should not trigger fallback"
1291        );
1292    }
1293
1294    #[test]
1295    fn test_try_source_fallback_nested_path() {
1296        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1297        let mut path_to_id = FxHashMap::default();
1298        path_to_id.insert(src_path.as_path(), FileId(2));
1299
1300        let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1301        assert_eq!(
1302            try_source_fallback(&dist_path, &path_to_id),
1303            Some(FileId(2)),
1304            "nested dist path should fall back to nested src path"
1305        );
1306    }
1307
1308    #[test]
1309    fn test_try_source_fallback_nested_dist_esm() {
1310        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1311        let mut path_to_id = FxHashMap::default();
1312        path_to_id.insert(src_path.as_path(), FileId(0));
1313
1314        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1315        assert_eq!(
1316            try_source_fallback(&dist_path, &path_to_id),
1317            Some(FileId(0)),
1318            "dist/esm/utils.mjs should fall back to src/utils.ts"
1319        );
1320    }
1321
1322    #[test]
1323    fn test_try_source_fallback_nested_build_cjs() {
1324        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1325        let mut path_to_id = FxHashMap::default();
1326        path_to_id.insert(src_path.as_path(), FileId(1));
1327
1328        let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1329        assert_eq!(
1330            try_source_fallback(&build_path, &path_to_id),
1331            Some(FileId(1)),
1332            "build/cjs/index.cjs should fall back to src/index.ts"
1333        );
1334    }
1335
1336    #[test]
1337    fn test_try_source_fallback_nested_dist_esm_deep_path() {
1338        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1339        let mut path_to_id = FxHashMap::default();
1340        path_to_id.insert(src_path.as_path(), FileId(2));
1341
1342        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1343        assert_eq!(
1344            try_source_fallback(&dist_path, &path_to_id),
1345            Some(FileId(2)),
1346            "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1347        );
1348    }
1349
1350    #[test]
1351    fn test_try_source_fallback_triple_nested_output_dirs() {
1352        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1353        let mut path_to_id = FxHashMap::default();
1354        path_to_id.insert(src_path.as_path(), FileId(0));
1355
1356        let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1357        assert_eq!(
1358            try_source_fallback(&dist_path, &path_to_id),
1359            Some(FileId(0)),
1360            "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1361        );
1362    }
1363
1364    #[test]
1365    fn test_try_source_fallback_parent_dir_named_build() {
1366        let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1367        let mut path_to_id = FxHashMap::default();
1368        path_to_id.insert(src_path.as_path(), FileId(0));
1369
1370        let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1371        assert_eq!(
1372            try_source_fallback(&dist_path, &path_to_id),
1373            Some(FileId(0)),
1374            "should resolve dist/ within project, not match parent 'build' dir"
1375        );
1376    }
1377
1378    #[test]
1379    fn package_map_exact_entry_beats_pattern_entry() {
1380        let map = serde_json::json!({
1381            "#nitro/runtime/task": "./dist/special/task.mjs",
1382            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1383        });
1384        assert_eq!(
1385            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1386            PackageMapTarget::Targets(vec!["./dist/special/task.mjs".to_string()])
1387        );
1388    }
1389
1390    #[test]
1391    fn package_map_wildcard_substitutes_capture() {
1392        let map = serde_json::json!({
1393            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1394        });
1395        assert_eq!(
1396            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1397            PackageMapTarget::Targets(vec!["./dist/runtime/internal/task.mjs".to_string()])
1398        );
1399    }
1400
1401    #[test]
1402    fn package_map_exact_entry_with_no_target_blocks_pattern_entry() {
1403        let map = serde_json::json!({
1404            "#nitro/runtime/task": null,
1405            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1406        });
1407        assert_eq!(
1408            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1409            PackageMapTarget::Blocked
1410        );
1411    }
1412
1413    #[test]
1414    fn package_map_best_pattern_with_no_target_blocks_broader_pattern() {
1415        let map = serde_json::json!({
1416            "#nitro/runtime/internal/*": null,
1417            "#nitro/runtime/*": "./dist/runtime/*.mjs"
1418        });
1419        assert_eq!(
1420            package_map_target(&map, "#nitro/runtime/internal/task", &conditions()),
1421            PackageMapTarget::Blocked
1422        );
1423    }
1424
1425    #[test]
1426    fn package_map_unmatched_subpath_is_not_a_target() {
1427        let map = serde_json::json!({
1428            "./query": "./dist/query/index.js"
1429        });
1430        assert_eq!(
1431            package_map_target(&map, "./private", &conditions()),
1432            PackageMapTarget::NoMatch
1433        );
1434    }
1435
1436    #[test]
1437    fn package_map_nested_conditions_follow_manifest_order() {
1438        let map = serde_json::json!({
1439            "./query/react": {
1440                "types": "./dist/query/react/index.d.ts",
1441                "import": {
1442                    "development": "./src/query/react/index.ts",
1443                    "default": "./dist/query/react/index.js"
1444                },
1445                "default": "./dist/query/react/index.cjs"
1446            }
1447        });
1448        assert_eq!(
1449            package_map_target(&map, "./query/react", &conditions()),
1450            PackageMapTarget::Targets(vec!["./dist/query/react/index.d.ts".to_string()])
1451        );
1452    }
1453
1454    #[test]
1455    fn package_map_import_before_types_selects_runtime_branch() {
1456        let map = serde_json::json!({
1457            ".": {
1458                "import": "./dist/index.js",
1459                "types": "./dist/index.d.ts"
1460            }
1461        });
1462        assert_eq!(
1463            package_map_target(&map, ".", &conditions()),
1464            PackageMapTarget::Targets(vec!["./dist/index.js".to_string()])
1465        );
1466    }
1467
1468    #[test]
1469    fn package_map_condition_order_follows_manifest_order() {
1470        let map = serde_json::json!({
1471            ".": {
1472                "node": "./dist/node.js",
1473                "import": "./dist/index.js"
1474            }
1475        });
1476        assert_eq!(
1477            package_map_target(&map, ".", &conditions()),
1478            PackageMapTarget::Targets(vec!["./dist/node.js".to_string()])
1479        );
1480    }
1481
1482    #[test]
1483    fn package_map_arrays_preserve_fallback_order() {
1484        let map = serde_json::json!({
1485            "#array": ["./dist/missing.js", "./src/array.ts"],
1486            "#null": null,
1487            "#false": false
1488        });
1489        assert_eq!(
1490            package_map_target(&map, "#array", &conditions()),
1491            PackageMapTarget::Targets(vec![
1492                "./dist/missing.js".to_string(),
1493                "./src/array.ts".to_string()
1494            ])
1495        );
1496        assert_eq!(
1497            package_map_target(&map, "#null", &conditions()),
1498            PackageMapTarget::Blocked
1499        );
1500        assert_eq!(
1501            package_map_target(&map, "#false", &conditions()),
1502            PackageMapTarget::Blocked
1503        );
1504    }
1505
1506    #[test]
1507    fn package_map_non_relative_target_does_not_trigger_source_fallback() {
1508        with_package_map_ctx(
1509            PathBuf::from("/project"),
1510            Some("pkg"),
1511            fallow_config::PackageJson::default(),
1512            &[],
1513            |ctx, manifest, _| {
1514                assert!(resolve_package_map_target(ctx, manifest, "lodash", None).is_none());
1515                assert!(
1516                    resolve_package_map_target(ctx, manifest, "../dist/index.js", None).is_none()
1517                );
1518            },
1519        );
1520    }
1521
1522    #[test]
1523    fn package_map_targets_use_first_reachable_target() {
1524        let root = PathBuf::from("/project");
1525        let src_path = root.join("src/feature.ts");
1526        let targets = vec![
1527            "./dist/missing.js".to_string(),
1528            "./src/feature.ts".to_string(),
1529        ];
1530
1531        with_package_map_ctx(
1532            root,
1533            Some("pkg"),
1534            fallow_config::PackageJson::default(),
1535            &[(src_path, FileId(9))],
1536            |ctx, manifest, _| {
1537                assert_eq!(
1538                    resolve_package_map_targets(ctx, manifest, &targets, None),
1539                    Some(FileId(9))
1540                );
1541            },
1542        );
1543    }
1544
1545    #[test]
1546    fn package_imports_fallback_supports_external_package_targets() {
1547        let root = PathBuf::from("/project");
1548        with_package_map_ctx(
1549            root,
1550            Some("pkg"),
1551            fallow_config::PackageJson {
1552                imports: Some(serde_json::json!({
1553                    "#pad": "left-pad",
1554                    "#scoped": "@scope/pkg/subpath"
1555                })),
1556                ..Default::default()
1557            },
1558            &[],
1559            |ctx, _, root| {
1560                let pad = try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#pad");
1561                assert!(matches!(pad, Some(ResolveResult::NpmPackage(pkg)) if pkg == "left-pad"));
1562
1563                let scoped =
1564                    try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#scoped");
1565                assert!(
1566                    matches!(scoped, Some(ResolveResult::NpmPackage(pkg)) if pkg == "@scope/pkg")
1567                );
1568            },
1569        );
1570    }
1571
1572    #[test]
1573    fn package_imports_fallback_supports_unnamed_packages() {
1574        let root = PathBuf::from("/project");
1575        let src_path = root.join("src/runtime/task.ts");
1576        with_package_map_ctx(
1577            root,
1578            None,
1579            fallow_config::PackageJson {
1580                imports: Some(serde_json::json!({
1581                    "#runtime/*": "./dist/runtime/*.mjs"
1582                })),
1583                ..Default::default()
1584            },
1585            &[(src_path, FileId(7))],
1586            |ctx, _, root| {
1587                let result =
1588                    try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#runtime/task");
1589                assert!(matches!(
1590                    result,
1591                    Some(ResolveResult::InternalModule(FileId(7)))
1592                ));
1593            },
1594        );
1595    }
1596
1597    #[test]
1598    fn test_pnpm_store_path_extract_package_name() {
1599        // pnpm virtual store paths should correctly extract package name
1600        let path =
1601            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1602        assert_eq!(
1603            extract_package_name_from_node_modules_path(&path),
1604            Some("react".to_string())
1605        );
1606    }
1607
1608    #[test]
1609    fn test_pnpm_store_path_scoped_package() {
1610        let path = PathBuf::from(
1611            "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1612        );
1613        assert_eq!(
1614            extract_package_name_from_node_modules_path(&path),
1615            Some("@babel/core".to_string())
1616        );
1617    }
1618
1619    fn conditions() -> Vec<String> {
1620        vec![
1621            "development".to_string(),
1622            "import".to_string(),
1623            "require".to_string(),
1624            "default".to_string(),
1625            "types".to_string(),
1626            "node".to_string(),
1627        ]
1628    }
1629
1630    #[test]
1631    fn test_pnpm_store_path_with_peer_deps() {
1632        let path = PathBuf::from(
1633            "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
1634        );
1635        assert_eq!(
1636            extract_package_name_from_node_modules_path(&path),
1637            Some("webpack".to_string())
1638        );
1639    }
1640
1641    #[test]
1642    fn test_try_pnpm_workspace_fallback_dist_to_src() {
1643        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1644        let mut path_to_id = FxHashMap::default();
1645        path_to_id.insert(src_path.as_path(), FileId(0));
1646
1647        let mut workspace_roots = FxHashMap::default();
1648        let ws_root = PathBuf::from("/project/packages/ui");
1649        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1650
1651        // pnpm virtual store path with dist/ output
1652        let pnpm_path = PathBuf::from(
1653            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
1654        );
1655        assert_eq!(
1656            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1657            Some(FileId(0)),
1658            ".pnpm workspace path should fall back to src/utils.ts"
1659        );
1660    }
1661
1662    #[test]
1663    fn test_try_pnpm_workspace_fallback_direct_source() {
1664        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1665        let mut path_to_id = FxHashMap::default();
1666        path_to_id.insert(src_path.as_path(), FileId(1));
1667
1668        let mut workspace_roots = FxHashMap::default();
1669        let ws_root = PathBuf::from("/project/packages/core");
1670        workspace_roots.insert("@myorg/core", ws_root.as_path());
1671
1672        // pnpm path pointing directly to src/
1673        let pnpm_path = PathBuf::from(
1674            "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
1675        );
1676        assert_eq!(
1677            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1678            Some(FileId(1)),
1679            ".pnpm workspace path with src/ should resolve directly"
1680        );
1681    }
1682
1683    #[test]
1684    fn test_try_pnpm_workspace_fallback_non_workspace_package() {
1685        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1686
1687        let mut workspace_roots = FxHashMap::default();
1688        let ws_root = PathBuf::from("/project/packages/ui");
1689        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1690
1691        // External package (not a workspace) — should return None
1692        let pnpm_path =
1693            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1694        assert_eq!(
1695            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1696            None,
1697            "non-workspace package in .pnpm should return None"
1698        );
1699    }
1700
1701    #[test]
1702    fn test_try_pnpm_workspace_fallback_unscoped_package() {
1703        let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
1704        let mut path_to_id = FxHashMap::default();
1705        path_to_id.insert(src_path.as_path(), FileId(2));
1706
1707        let mut workspace_roots = FxHashMap::default();
1708        let ws_root = PathBuf::from("/project/packages/utils");
1709        workspace_roots.insert("my-utils", ws_root.as_path());
1710
1711        // Unscoped workspace package in pnpm store
1712        let pnpm_path = PathBuf::from(
1713            "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
1714        );
1715        assert_eq!(
1716            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1717            Some(FileId(2)),
1718            "unscoped workspace package in .pnpm should resolve"
1719        );
1720    }
1721
1722    #[test]
1723    fn test_try_pnpm_workspace_fallback_nested_path() {
1724        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1725        let mut path_to_id = FxHashMap::default();
1726        path_to_id.insert(src_path.as_path(), FileId(3));
1727
1728        let mut workspace_roots = FxHashMap::default();
1729        let ws_root = PathBuf::from("/project/packages/ui");
1730        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1731
1732        // Nested path within the package
1733        let pnpm_path = PathBuf::from(
1734            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1735        );
1736        assert_eq!(
1737            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1738            Some(FileId(3)),
1739            "nested .pnpm workspace path should resolve through source fallback"
1740        );
1741    }
1742
1743    #[test]
1744    fn test_try_pnpm_workspace_fallback_no_pnpm() {
1745        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1746        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1747
1748        // Regular path without .pnpm — should return None immediately
1749        let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1750        assert_eq!(
1751            try_pnpm_workspace_fallback(&regular_path, &path_to_id, &workspace_roots),
1752            None,
1753        );
1754    }
1755
1756    #[test]
1757    fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1758        let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1759        let mut path_to_id = FxHashMap::default();
1760        path_to_id.insert(src_path.as_path(), FileId(4));
1761
1762        let mut workspace_roots = FxHashMap::default();
1763        let ws_root = PathBuf::from("/project/packages/ui");
1764        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1765
1766        // pnpm path with peer dependency suffix
1767        let pnpm_path = PathBuf::from(
1768            "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1769        );
1770        assert_eq!(
1771            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1772            Some(FileId(4)),
1773            ".pnpm path with peer dep suffix should still resolve"
1774        );
1775    }
1776
1777    // ── make_glob_from_pattern ───────────────────────────────────────
1778
1779    #[test]
1780    fn make_glob_prefix_only_no_suffix() {
1781        let pattern = fallow_types::extract::DynamicImportPattern {
1782            prefix: "./locales/".to_string(),
1783            suffix: None,
1784            span: oxc_span::Span::default(),
1785        };
1786        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*");
1787    }
1788
1789    #[test]
1790    fn make_glob_prefix_with_suffix() {
1791        let pattern = fallow_types::extract::DynamicImportPattern {
1792            prefix: "./locales/".to_string(),
1793            suffix: Some(".json".to_string()),
1794            span: oxc_span::Span::default(),
1795        };
1796        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1797    }
1798
1799    #[test]
1800    fn make_glob_passthrough_star() {
1801        // Prefix already contains glob characters — use as-is
1802        let pattern = fallow_types::extract::DynamicImportPattern {
1803            prefix: "./pages/**/*.tsx".to_string(),
1804            suffix: None,
1805            span: oxc_span::Span::default(),
1806        };
1807        assert_eq!(make_glob_from_pattern(&pattern), "./pages/**/*.tsx");
1808    }
1809
1810    #[test]
1811    fn make_glob_passthrough_brace() {
1812        let pattern = fallow_types::extract::DynamicImportPattern {
1813            prefix: "./i18n/{en,de,fr}.json".to_string(),
1814            suffix: None,
1815            span: oxc_span::Span::default(),
1816        };
1817        assert_eq!(make_glob_from_pattern(&pattern), "./i18n/{en,de,fr}.json");
1818    }
1819
1820    #[test]
1821    fn make_glob_empty_prefix_no_suffix() {
1822        let pattern = fallow_types::extract::DynamicImportPattern {
1823            prefix: String::new(),
1824            suffix: None,
1825            span: oxc_span::Span::default(),
1826        };
1827        assert_eq!(make_glob_from_pattern(&pattern), "*");
1828    }
1829
1830    #[test]
1831    fn make_glob_empty_prefix_with_suffix() {
1832        let pattern = fallow_types::extract::DynamicImportPattern {
1833            prefix: String::new(),
1834            suffix: Some(".ts".to_string()),
1835            span: oxc_span::Span::default(),
1836        };
1837        assert_eq!(make_glob_from_pattern(&pattern), "*.ts");
1838    }
1839
1840    // ── make_glob_from_pattern: template literal patterns ──────────
1841
1842    #[test]
1843    fn make_glob_template_literal_prefix_only() {
1844        // `./pages/${page}` extracts prefix="./pages/", suffix=None
1845        let pattern = fallow_types::extract::DynamicImportPattern {
1846            prefix: "./pages/".to_string(),
1847            suffix: None,
1848            span: oxc_span::Span::default(),
1849        };
1850        assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1851    }
1852
1853    #[test]
1854    fn make_glob_template_literal_with_extension_suffix() {
1855        // `./locales/${lang}.json` extracts prefix="./locales/", suffix=".json"
1856        let pattern = fallow_types::extract::DynamicImportPattern {
1857            prefix: "./locales/".to_string(),
1858            suffix: Some(".json".to_string()),
1859            span: oxc_span::Span::default(),
1860        };
1861        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1862    }
1863
1864    #[test]
1865    fn make_glob_template_literal_deep_prefix() {
1866        // `./modules/${area}/components/${name}.tsx`
1867        // Extractor captures prefix="./modules/", suffix=None (only first dynamic part)
1868        let pattern = fallow_types::extract::DynamicImportPattern {
1869            prefix: "./modules/".to_string(),
1870            suffix: None,
1871            span: oxc_span::Span::default(),
1872        };
1873        assert_eq!(make_glob_from_pattern(&pattern), "./modules/*");
1874    }
1875
1876    #[test]
1877    fn make_glob_string_concat_prefix() {
1878        // `'./pages/' + name` extracts prefix="./pages/", suffix=None
1879        let pattern = fallow_types::extract::DynamicImportPattern {
1880            prefix: "./pages/".to_string(),
1881            suffix: None,
1882            span: oxc_span::Span::default(),
1883        };
1884        assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1885    }
1886
1887    #[test]
1888    fn make_glob_string_concat_with_extension() {
1889        // `'./views/' + name + '.vue'` extracts prefix="./views/", suffix=".vue"
1890        let pattern = fallow_types::extract::DynamicImportPattern {
1891            prefix: "./views/".to_string(),
1892            suffix: Some(".vue".to_string()),
1893            span: oxc_span::Span::default(),
1894        };
1895        assert_eq!(make_glob_from_pattern(&pattern), "./views/*.vue");
1896    }
1897
1898    // ── make_glob_from_pattern: import.meta.glob ──────────────────
1899
1900    #[test]
1901    fn make_glob_import_meta_glob_recursive() {
1902        // import.meta.glob('./components/**/*.vue')
1903        let pattern = fallow_types::extract::DynamicImportPattern {
1904            prefix: "./components/**/*.vue".to_string(),
1905            suffix: None,
1906            span: oxc_span::Span::default(),
1907        };
1908        assert_eq!(
1909            make_glob_from_pattern(&pattern),
1910            "./components/**/*.vue",
1911            "import.meta.glob patterns with * should pass through as-is"
1912        );
1913    }
1914
1915    #[test]
1916    fn make_glob_import_meta_glob_brace_expansion() {
1917        // import.meta.glob('./plugins/{auth,analytics}.ts')
1918        let pattern = fallow_types::extract::DynamicImportPattern {
1919            prefix: "./plugins/{auth,analytics}.ts".to_string(),
1920            suffix: None,
1921            span: oxc_span::Span::default(),
1922        };
1923        assert_eq!(
1924            make_glob_from_pattern(&pattern),
1925            "./plugins/{auth,analytics}.ts",
1926            "import.meta.glob patterns with braces should pass through as-is"
1927        );
1928    }
1929
1930    #[test]
1931    fn make_glob_import_meta_glob_star_with_brace() {
1932        // import.meta.glob('./routes/**/*.{ts,tsx}')
1933        let pattern = fallow_types::extract::DynamicImportPattern {
1934            prefix: "./routes/**/*.{ts,tsx}".to_string(),
1935            suffix: None,
1936            span: oxc_span::Span::default(),
1937        };
1938        assert_eq!(
1939            make_glob_from_pattern(&pattern),
1940            "./routes/**/*.{ts,tsx}",
1941            "combined * and brace patterns should pass through"
1942        );
1943    }
1944
1945    #[test]
1946    fn make_glob_import_meta_glob_ignores_suffix_when_star_present() {
1947        // Edge case: prefix contains *, suffix is provided (unlikely but defensive)
1948        let pattern = fallow_types::extract::DynamicImportPattern {
1949            prefix: "./*.ts".to_string(),
1950            suffix: Some(".extra".to_string()),
1951            span: oxc_span::Span::default(),
1952        };
1953        assert_eq!(
1954            make_glob_from_pattern(&pattern),
1955            "./*.ts",
1956            "when prefix has glob chars, suffix is ignored (prefix used as-is)"
1957        );
1958    }
1959
1960    // ── make_glob_from_pattern: edge cases ────────────────────────
1961
1962    #[test]
1963    fn make_glob_single_dot_prefix() {
1964        let pattern = fallow_types::extract::DynamicImportPattern {
1965            prefix: "./".to_string(),
1966            suffix: None,
1967            span: oxc_span::Span::default(),
1968        };
1969        assert_eq!(make_glob_from_pattern(&pattern), "./*");
1970    }
1971
1972    #[test]
1973    fn make_glob_prefix_without_trailing_slash() {
1974        // `'./config' + ext` -> prefix="./config", suffix might be extension
1975        let pattern = fallow_types::extract::DynamicImportPattern {
1976            prefix: "./config".to_string(),
1977            suffix: None,
1978            span: oxc_span::Span::default(),
1979        };
1980        assert_eq!(make_glob_from_pattern(&pattern), "./config*");
1981    }
1982
1983    #[test]
1984    fn make_glob_prefix_with_dotdot() {
1985        let pattern = fallow_types::extract::DynamicImportPattern {
1986            prefix: "../shared/".to_string(),
1987            suffix: Some(".ts".to_string()),
1988            span: oxc_span::Span::default(),
1989        };
1990        assert_eq!(make_glob_from_pattern(&pattern), "../shared/*.ts");
1991    }
1992
1993    // ── extract_package_name: additional edge cases ───────────────
1994
1995    #[test]
1996    fn test_extract_package_name_with_pnpm_plus_encoded_scope() {
1997        // pnpm encodes @scope/pkg as @scope+pkg in store path
1998        // but the inner node_modules still uses the real scope
1999        let path = PathBuf::from(
2000            "/project/node_modules/.pnpm/@mui+material@5.15.0/node_modules/@mui/material/index.js",
2001        );
2002        assert_eq!(
2003            extract_package_name_from_node_modules_path(&path),
2004            Some("@mui/material".to_string())
2005        );
2006    }
2007
2008    #[test]
2009    fn test_extract_package_name_windows_style_path() {
2010        // Windows-style paths should still work since we filter for Normal components
2011        let path = PathBuf::from("/project/node_modules/typescript/lib/tsc.js");
2012        assert_eq!(
2013            extract_package_name_from_node_modules_path(&path),
2014            Some("typescript".to_string())
2015        );
2016    }
2017
2018    // ── try_source_fallback: additional output dir patterns ───────
2019
2020    #[test]
2021    fn test_try_source_fallback_out_dir() {
2022        let src_path = PathBuf::from("/project/packages/api/src/handler.ts");
2023        let mut path_to_id = FxHashMap::default();
2024        path_to_id.insert(src_path.as_path(), FileId(5));
2025
2026        let out_path = PathBuf::from("/project/packages/api/out/handler.js");
2027        assert_eq!(
2028            try_source_fallback(&out_path, &path_to_id),
2029            Some(FileId(5)),
2030            "out/handler.js should fall back to src/handler.ts"
2031        );
2032    }
2033
2034    #[test]
2035    fn test_try_source_fallback_mts_extension() {
2036        let src_path = PathBuf::from("/project/packages/lib/src/utils.mts");
2037        let mut path_to_id = FxHashMap::default();
2038        path_to_id.insert(src_path.as_path(), FileId(6));
2039
2040        let dist_path = PathBuf::from("/project/packages/lib/dist/utils.mjs");
2041        assert_eq!(
2042            try_source_fallback(&dist_path, &path_to_id),
2043            Some(FileId(6)),
2044            "dist/utils.mjs should fall back to src/utils.mts"
2045        );
2046    }
2047
2048    #[test]
2049    fn test_try_source_fallback_cts_extension() {
2050        let src_path = PathBuf::from("/project/packages/lib/src/config.cts");
2051        let mut path_to_id = FxHashMap::default();
2052        path_to_id.insert(src_path.as_path(), FileId(7));
2053
2054        let dist_path = PathBuf::from("/project/packages/lib/dist/config.cjs");
2055        assert_eq!(
2056            try_source_fallback(&dist_path, &path_to_id),
2057            Some(FileId(7)),
2058            "dist/config.cjs should fall back to src/config.cts"
2059        );
2060    }
2061
2062    #[test]
2063    fn test_try_source_fallback_jsx_extension() {
2064        let src_path = PathBuf::from("/project/packages/ui/src/App.jsx");
2065        let mut path_to_id = FxHashMap::default();
2066        path_to_id.insert(src_path.as_path(), FileId(8));
2067
2068        let build_path = PathBuf::from("/project/packages/ui/build/App.js");
2069        assert_eq!(
2070            try_source_fallback(&build_path, &path_to_id),
2071            Some(FileId(8)),
2072            "build/App.js should fall back to src/App.jsx"
2073        );
2074    }
2075
2076    #[test]
2077    fn test_try_source_fallback_no_file_stem() {
2078        // Path with no filename at all should return None gracefully
2079        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2080        let dist_path = PathBuf::from("/project/packages/ui/dist/");
2081        assert_eq!(
2082            try_source_fallback(&dist_path, &path_to_id),
2083            None,
2084            "directory path with no file should return None"
2085        );
2086    }
2087
2088    #[test]
2089    fn test_try_source_fallback_esm_subdir() {
2090        // esm is an output directory, so dist/esm -> src
2091        let src_path = PathBuf::from("/project/lib/src/index.ts");
2092        let mut path_to_id = FxHashMap::default();
2093        path_to_id.insert(src_path.as_path(), FileId(10));
2094
2095        let dist_path = PathBuf::from("/project/lib/esm/index.mjs");
2096        assert_eq!(
2097            try_source_fallback(&dist_path, &path_to_id),
2098            Some(FileId(10)),
2099            "standalone esm/ directory should fall back to src/"
2100        );
2101    }
2102
2103    #[test]
2104    fn test_try_source_fallback_cjs_subdir() {
2105        let src_path = PathBuf::from("/project/lib/src/index.ts");
2106        let mut path_to_id = FxHashMap::default();
2107        path_to_id.insert(src_path.as_path(), FileId(11));
2108
2109        let cjs_path = PathBuf::from("/project/lib/cjs/index.cjs");
2110        assert_eq!(
2111            try_source_fallback(&cjs_path, &path_to_id),
2112            Some(FileId(11)),
2113            "standalone cjs/ directory should fall back to src/"
2114        );
2115    }
2116
2117    // ── try_pnpm_workspace_fallback: edge cases ──────────────────
2118
2119    #[test]
2120    fn test_try_pnpm_workspace_fallback_empty_after_pnpm() {
2121        // Path that has .pnpm but nothing after the inner node_modules
2122        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2123        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2124
2125        let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/node_modules");
2126        assert_eq!(
2127            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2128            None,
2129            "path ending at node_modules with nothing after should return None"
2130        );
2131    }
2132
2133    #[test]
2134    fn test_try_pnpm_workspace_fallback_scoped_package_only_scope() {
2135        // Path has .pnpm/inner-node_modules/@scope but no package name after scope
2136        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2137        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2138
2139        let pnpm_path =
2140            PathBuf::from("/project/node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope");
2141        assert_eq!(
2142            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2143            None,
2144            "scoped package without full name and no matching workspace should return None"
2145        );
2146    }
2147
2148    #[test]
2149    fn test_try_pnpm_workspace_fallback_no_inner_node_modules() {
2150        // Path has .pnpm but no inner node_modules
2151        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2152        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2153
2154        let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/dist/index.js");
2155        assert_eq!(
2156            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2157            None,
2158            "path without inner node_modules after .pnpm should return None"
2159        );
2160    }
2161
2162    #[test]
2163    fn test_try_pnpm_workspace_fallback_package_without_relative_path() {
2164        // Path ends right at the package name, no file path after it
2165        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2166        let mut workspace_roots = FxHashMap::default();
2167        let ws_root = PathBuf::from("/project/packages/ui");
2168        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2169
2170        let pnpm_path =
2171            PathBuf::from("/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui");
2172        assert_eq!(
2173            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2174            None,
2175            "path ending at package name with no relative file should return None"
2176        );
2177    }
2178
2179    #[test]
2180    fn test_try_pnpm_workspace_fallback_nested_dist_esm() {
2181        let src_path = PathBuf::from("/project/packages/ui/src/Button.ts");
2182        let mut path_to_id = FxHashMap::default();
2183        path_to_id.insert(src_path.as_path(), FileId(10));
2184
2185        let mut workspace_roots = FxHashMap::default();
2186        let ws_root = PathBuf::from("/project/packages/ui");
2187        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2188
2189        // Nested output dirs within pnpm workspace path
2190        let pnpm_path = PathBuf::from(
2191            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/esm/Button.mjs",
2192        );
2193        assert_eq!(
2194            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2195            Some(FileId(10)),
2196            "pnpm path with nested dist/esm should resolve through source fallback"
2197        );
2198    }
2199}