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