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;
9
10use fallow_types::discover::FileId;
11
12use super::types::{OUTPUT_DIRS, ResolveContext, ResolveResult, SOURCE_EXTS};
13
14/// Try resolving a specifier using plugin-provided path aliases.
15///
16/// Substitutes a matching alias prefix (e.g., `~/`) with a directory relative to the
17/// project root (e.g., `app/`) and resolves the resulting path. This handles framework
18/// aliases like Nuxt's `~/`, `~~/`, `#shared/` that aren't defined in tsconfig.json
19/// but map to real filesystem paths.
20pub(super) fn try_path_alias_fallback(
21    ctx: &ResolveContext<'_>,
22    specifier: &str,
23) -> Option<ResolveResult> {
24    for (prefix, replacement) in ctx.path_aliases {
25        if !specifier.starts_with(prefix.as_str()) {
26            continue;
27        }
28
29        let remainder = &specifier[prefix.len()..];
30        // Build the substituted path relative to root.
31        // If replacement is empty, remainder is relative to root directly.
32        let substituted = if replacement.is_empty() {
33            format!("./{remainder}")
34        } else {
35            format!("./{replacement}/{remainder}")
36        };
37
38        // Resolve relative to the project root directly. These plugin-provided
39        // aliases have already been normalized to root-relative paths, so
40        // tsconfig discovery is not needed here and can actually hurt for
41        // solution-style roots (`tsconfig.json` with only `references`).
42        if let Ok(resolved) = ctx.resolver.resolve(ctx.root, &substituted) {
43            let resolved_path = resolved.path();
44            // Try raw path lookup first
45            if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
46                return Some(ResolveResult::InternalModule(file_id));
47            }
48            // Fall back to canonical path lookup
49            if let Ok(canonical) = dunce::canonicalize(resolved_path) {
50                if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
51                    return Some(ResolveResult::InternalModule(file_id));
52                }
53                if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
54                    return Some(ResolveResult::InternalModule(file_id));
55                }
56                if let Some(file_id) =
57                    try_pnpm_workspace_fallback(&canonical, ctx.path_to_id, ctx.workspace_roots)
58                {
59                    return Some(ResolveResult::InternalModule(file_id));
60                }
61                if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
62                    return Some(ResolveResult::NpmPackage(pkg_name));
63                }
64                return Some(ResolveResult::ExternalFile(canonical));
65            }
66        }
67    }
68    None
69}
70
71/// Try SCSS partial resolution: `_filename` and `_index` conventions.
72///
73/// SCSS resolves imports in this order:
74/// 1. `@use 'variables'` → `_variables.scss` (partial convention)
75/// 2. `@use 'components'` → `components/_index.scss` or `components/index.scss` (directory index)
76///
77/// Handles both relative (`../styles/variables`) and bare (`variables`) specifiers
78/// that were normalized to `./variables` during extraction.
79pub(super) fn try_scss_partial_fallback(
80    ctx: &ResolveContext<'_>,
81    from_file: &Path,
82    specifier: &str,
83) -> Option<ResolveResult> {
84    // SCSS built-in modules (`sass:math`) should not be retried
85    if specifier.contains(':') {
86        return None;
87    }
88
89    let spec_path = Path::new(specifier);
90    let filename = spec_path.file_name()?.to_str()?;
91
92    // Already has underscore prefix
93    if filename.starts_with('_') {
94        return None;
95    }
96
97    // 1. Try partial convention: prepend _ to the filename
98    let partial_filename = format!("_{filename}");
99    let partial_specifier = if let Some(parent) = spec_path.parent()
100        && !parent.as_os_str().is_empty()
101    {
102        format!("{}/{partial_filename}", parent.display())
103    } else {
104        partial_filename
105    };
106
107    if let Some(result) = try_resolve_scss(ctx, from_file, &partial_specifier) {
108        return Some(result);
109    }
110
111    // 2. Try directory index convention: specifier/_index and specifier/index
112    let index_partial = format!("{specifier}/_index");
113    if let Some(result) = try_resolve_scss(ctx, from_file, &index_partial) {
114        return Some(result);
115    }
116
117    let index_plain = format!("{specifier}/index");
118    try_resolve_scss(ctx, from_file, &index_plain)
119}
120
121/// Attempt to resolve a single SCSS specifier and map to an internal module.
122fn try_resolve_scss(
123    ctx: &ResolveContext<'_>,
124    from_file: &Path,
125    specifier: &str,
126) -> Option<ResolveResult> {
127    let resolved = ctx.resolver.resolve_file(from_file, specifier).ok()?;
128    let resolved_path = resolved.path();
129
130    if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
131        return Some(ResolveResult::InternalModule(file_id));
132    }
133    if let Ok(canonical) = dunce::canonicalize(resolved_path)
134        && let Some(&file_id) = ctx.path_to_id.get(canonical.as_path())
135    {
136        return Some(ResolveResult::InternalModule(file_id));
137    }
138    None
139}
140
141/// Try SCSS `includePaths` fallback: resolve the specifier against each
142/// framework-contributed include directory.
143///
144/// Angular's `stylePreprocessorOptions.includePaths` (and Nx's equivalent via
145/// project.json) adds extra search paths that SCSS resolves against before
146/// falling back to node_modules. Bare `@use 'variables'` statements that were
147/// normalized to `./variables` at extraction time fail the usual file-local
148/// resolution, so when the importing file is `.scss`/`.sass` and the spec
149/// originated from such a bare specifier, we retry against each include path,
150/// applying the SCSS partial (`_variables`) and directory-index conventions.
151///
152/// The specifier arrives with a `./` prefix because `normalize_css_import_path`
153/// rewrites bare extensionless SCSS specifiers to relative ones. We strip that
154/// prefix here to re-enter the include-path search from the root of each
155/// directory. Relative specifiers that already escape the importing file
156/// (e.g. `../shared/variables`) are left untouched — include paths only
157/// disambiguate bare specifiers, not explicit relative paths.
158pub(super) fn try_scss_include_path_fallback(
159    ctx: &ResolveContext<'_>,
160    from_file: &Path,
161    specifier: &str,
162) -> Option<ResolveResult> {
163    if ctx.scss_include_paths.is_empty() {
164        return None;
165    }
166    if !from_file
167        .extension()
168        .is_some_and(|e| e == "scss" || e == "sass")
169    {
170        return None;
171    }
172    // SCSS built-in modules (`sass:math`) should not be retried
173    if specifier.contains(':') {
174        return None;
175    }
176    // Only bare (normalized) specifiers benefit from include-path search.
177    // Parent-relative specifiers like `../shared/vars` explicitly escape the
178    // importing file's directory and should not be silently redirected.
179    let bare = specifier.strip_prefix("./")?;
180    if bare.starts_with("..") || bare.starts_with('/') {
181        return None;
182    }
183
184    for include_dir in ctx.scss_include_paths {
185        if let Some(file_id) = find_scss_in_dir(include_dir, bare, ctx) {
186            return Some(ResolveResult::InternalModule(file_id));
187        }
188    }
189    None
190}
191
192/// Probe an SCSS include directory for a bare specifier, applying the standard
193/// SCSS resolution order: exact file, `_`-prefixed partial, `_index` / `index`
194/// directory conventions. Supports `.scss` and `.sass` extensions.
195fn find_scss_in_dir(include_dir: &Path, bare: &str, ctx: &ResolveContext<'_>) -> Option<FileId> {
196    let bare_path = Path::new(bare);
197    let has_scss_ext = matches!(
198        bare_path.extension().and_then(|e| e.to_str()),
199        Some(ext) if ext.eq_ignore_ascii_case("scss") || ext.eq_ignore_ascii_case("sass")
200    );
201
202    // Split bare spec so we can build the `_`-prefixed partial for the final
203    // component while preserving any leading directory segments.
204    let parent = bare_path.parent();
205    let stem_with_ext = bare_path.file_name()?.to_str()?;
206    let stem_without_ext = bare_path.file_stem().and_then(|s| s.to_str())?;
207
208    let build = |rel: &Path| -> std::path::PathBuf { include_dir.join(rel) };
209    let join_with_parent = |name: &str| -> std::path::PathBuf {
210        parent.map_or_else(|| build(Path::new(name)), |p| build(&p.join(name)))
211    };
212
213    let exts: &[&str] = if has_scss_ext {
214        &[""]
215    } else {
216        &["scss", "sass"]
217    };
218
219    for ext in exts {
220        let suffix = if ext.is_empty() {
221            String::new()
222        } else {
223            format!(".{ext}")
224        };
225        // 1. Direct file: include_dir/<bare><ext>
226        let direct = if ext.is_empty() {
227            build(bare_path)
228        } else {
229            join_with_parent(&format!("{stem_with_ext}{suffix}"))
230        };
231        if let Some(fid) = lookup_scss_path(&direct, ctx) {
232            return Some(fid);
233        }
234        // 2. Partial: include_dir/<parent>/_<stem><ext>
235        let partial_name = if ext.is_empty() {
236            format!("_{stem_with_ext}")
237        } else {
238            format!("_{stem_without_ext}{suffix}")
239        };
240        let partial = join_with_parent(&partial_name);
241        if let Some(fid) = lookup_scss_path(&partial, ctx) {
242            return Some(fid);
243        }
244        if ext.is_empty() {
245            // Already has extension; directory index candidates below don't apply.
246            continue;
247        }
248        // 3. Directory index: include_dir/<bare>/_index.<ext>
249        let idx_partial = build(bare_path).join(format!("_index{suffix}"));
250        if let Some(fid) = lookup_scss_path(&idx_partial, ctx) {
251            return Some(fid);
252        }
253        let idx_plain = build(bare_path).join(format!("index{suffix}"));
254        if let Some(fid) = lookup_scss_path(&idx_plain, ctx) {
255            return Some(fid);
256        }
257    }
258    None
259}
260
261/// Look up an absolute candidate path in the file index, falling back to
262/// canonical path lookup for intra-project symlinks.
263fn lookup_scss_path(candidate: &Path, ctx: &ResolveContext<'_>) -> Option<FileId> {
264    if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
265        return Some(file_id);
266    }
267    if let Ok(canonical) = dunce::canonicalize(candidate) {
268        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
269            return Some(file_id);
270        }
271        if let Some(fallback) = ctx.canonical_fallback
272            && let Some(file_id) = fallback.get(&canonical)
273        {
274            return Some(file_id);
275        }
276    }
277    None
278}
279
280/// Try SCSS `node_modules` fallback: resolve a bare specifier by walking up
281/// from the importing file and probing each ancestor's `node_modules/` dir.
282///
283/// Sass's `@import` / `@use` resolution algorithm searches `node_modules/` for
284/// bare specifiers after the file-local and `includePaths` searches fail.
285/// `@import 'bootstrap/scss/functions'` resolves to
286/// `node_modules/bootstrap/scss/_functions.scss` via the standard partial
287/// convention; `@import 'animate.css/animate.min'` resolves to
288/// `node_modules/animate.css/animate.min.css` via the CSS-extension fallback.
289///
290/// Files inside `node_modules/` are not in fallow's file index (the default
291/// ignore patterns exclude them), so this function returns
292/// `ResolveResult::NpmPackage` when a candidate exists on disk. That ensures
293/// (1) the `@import` is not reported as unresolved and (2) the npm package is
294/// marked as a used dependency so `unused-dependencies` / `unlisted-dependencies`
295/// stay accurate.
296///
297/// The specifier arrives with a `./` prefix because `normalize_css_import_path`
298/// rewrites bare extensionless SCSS specifiers to relative ones. Parent-relative
299/// specifiers are skipped — they explicitly escape the importing file and must
300/// not be silently redirected to `node_modules`. See issue #125.
301pub(super) fn try_scss_node_modules_fallback(
302    _ctx: &ResolveContext<'_>,
303    from_file: &Path,
304    specifier: &str,
305) -> Option<ResolveResult> {
306    // SCSS built-in modules (`sass:math`) should not be retried
307    if specifier.contains(':') {
308        return None;
309    }
310    if !from_file
311        .extension()
312        .is_some_and(|e| e == "scss" || e == "sass")
313    {
314        return None;
315    }
316    // Only bare (normalized) specifiers should search node_modules. Explicit
317    // parent-relative paths (`../shared/vars`) are intentional and must not be
318    // redirected.
319    let bare = specifier.strip_prefix("./")?;
320    if bare.starts_with("..") || bare.starts_with('/') {
321        return None;
322    }
323    // The first segment of a bare specifier is the package name (or the start
324    // of a scoped package name). Require it before probing node_modules to
325    // avoid spurious syscalls on malformed specifiers.
326    if bare.is_empty() {
327        return None;
328    }
329
330    // Walk up from the importing file's parent directory to the filesystem
331    // root, matching Node.js / Sass `node_modules` resolution. Covers all
332    // common layouts: flat single project, non-hoisted monorepo, and hoisted
333    // monorepo where `node_modules` lives above the fallow project root
334    // (e.g., fallow run on `/monorepo/packages/my-lib` needs to reach
335    // `/monorepo/node_modules`). The walk is bounded by `Path::parent()`
336    // returning `None` at the filesystem root.
337    let mut dir = from_file.parent()?;
338    loop {
339        let nm_dir = dir.join("node_modules");
340        if nm_dir.is_dir()
341            && let Some(path) = find_scss_in_node_modules(&nm_dir, bare)
342            && let Some(pkg_name) = extract_package_name_from_node_modules_path(&path)
343        {
344            return Some(ResolveResult::NpmPackage(pkg_name));
345        }
346        let Some(parent) = dir.parent() else {
347            break;
348        };
349        dir = parent;
350    }
351    None
352}
353
354/// Probe candidate filesystem paths for a bare SCSS specifier inside a single
355/// `node_modules/` directory, applying Sass resolution conventions.
356///
357/// Candidate order:
358/// 1. `<bare>.scss` / `<bare>.sass` / `<bare>.css` (extension append)
359/// 2. `<parent>/_<stem>.scss` / `<parent>/_<stem>.sass` (partial convention)
360/// 3. `<bare>/_index.scss` / `<bare>/index.scss` (and `.sass` variants)
361/// 4. `<bare>` (exact, for specifiers that already carry an extension)
362fn find_scss_in_node_modules(nm_dir: &Path, bare: &str) -> Option<PathBuf> {
363    let bare_path = Path::new(bare);
364    let file_name = bare_path.file_name()?.to_str()?;
365    let parent = bare_path.parent();
366    let join_with_parent = |name: &str| -> PathBuf {
367        parent.map_or_else(|| nm_dir.join(name), |p| nm_dir.join(p).join(name))
368    };
369
370    // 1. Append extension. Covers both SCSS partials (with ext .scss/.sass
371    // added via the separate partial probe below) and CSS files where Sass
372    // appends `.css` to an extensionless specifier like `animate.css/animate.min`.
373    for ext in &["scss", "sass", "css"] {
374        let candidate = join_with_parent(&format!("{file_name}.{ext}"));
375        if candidate.is_file() {
376            return Some(candidate);
377        }
378    }
379    // 2. SCSS partial: prepend underscore to the file name component only.
380    // Skip `.css` here — CSS has no partial convention.
381    for ext in &["scss", "sass"] {
382        let candidate = join_with_parent(&format!("_{file_name}.{ext}"));
383        if candidate.is_file() {
384            return Some(candidate);
385        }
386    }
387    // 3. Directory index: `<bare>/_index.<ext>` or `<bare>/index.<ext>`.
388    for ext in &["scss", "sass"] {
389        let idx_partial = nm_dir.join(bare).join(format!("_index.{ext}"));
390        if idx_partial.is_file() {
391            return Some(idx_partial);
392        }
393        let idx_plain = nm_dir.join(bare).join(format!("index.{ext}"));
394        if idx_plain.is_file() {
395            return Some(idx_plain);
396        }
397    }
398    // 4. Exact file — covers specifiers that already carry an extension
399    // (e.g., `bootstrap/dist/css/bootstrap.min.css`).
400    let exact = nm_dir.join(bare);
401    if exact.is_file() {
402        return Some(exact);
403    }
404    None
405}
406
407/// Try to map a resolved output path (e.g., `packages/ui/dist/utils.js`) back to
408/// the corresponding source file (e.g., `packages/ui/src/utils.ts`).
409///
410/// This handles cross-workspace imports that go through `exports` maps pointing to
411/// built output directories. Since fallow ignores `dist/`, `build/`, etc. by default,
412/// the resolved path won't be in the file set, but the source file will be.
413///
414/// Nested output subdirectories (e.g., `dist/esm/utils.mjs`, `build/cjs/index.cjs`)
415/// are handled by finding the last output directory component (closest to the file,
416/// avoiding false matches on parent directories) and then walking backwards to collect
417/// all consecutive output directory components before it.
418pub(super) fn try_source_fallback(
419    resolved: &Path,
420    path_to_id: &FxHashMap<&Path, FileId>,
421) -> Option<FileId> {
422    let components: Vec<_> = resolved.components().collect();
423
424    let is_output_dir = |c: &std::path::Component| -> bool {
425        if let std::path::Component::Normal(s) = c
426            && let Some(name) = s.to_str()
427        {
428            return OUTPUT_DIRS.contains(&name);
429        }
430        false
431    };
432
433    // Find the LAST output directory component (closest to the file).
434    // Using rposition avoids false matches on parent directories that happen to
435    // be named "build", "dist", etc.
436    let last_output_pos = components.iter().rposition(&is_output_dir)?;
437
438    // Walk backwards to find the start of consecutive output directory components.
439    // e.g., for `dist/esm/utils.mjs`, rposition finds `esm`, then we walk back to `dist`.
440    let mut first_output_pos = last_output_pos;
441    while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
442        first_output_pos -= 1;
443    }
444
445    // Build the path prefix (everything before the first consecutive output dir)
446    let prefix: PathBuf = components[..first_output_pos].iter().collect();
447
448    // Build the relative path after the last consecutive output dir
449    let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
450    suffix.file_stem()?; // Ensure the suffix has a filename
451
452    // Try replacing the output dirs with "src" and each source extension
453    for ext in SOURCE_EXTS {
454        let source_candidate = prefix.join("src").join(suffix.with_extension(ext));
455        if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
456            return Some(file_id);
457        }
458    }
459
460    None
461}
462
463/// Extract npm package name from a resolved path inside `node_modules`.
464///
465/// Given a path like `/project/node_modules/react/index.js`, returns `Some("react")`.
466/// Given a path like `/project/node_modules/@scope/pkg/dist/index.js`, returns `Some("@scope/pkg")`.
467/// Returns `None` if the path doesn't contain a `node_modules` segment.
468pub fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
469    let components: Vec<&str> = path
470        .components()
471        .filter_map(|c| match c {
472            std::path::Component::Normal(s) => s.to_str(),
473            _ => None,
474        })
475        .collect();
476
477    // Find the last "node_modules" component (handles nested node_modules)
478    let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
479
480    let after = &components[nm_idx + 1..];
481    if after.is_empty() {
482        return None;
483    }
484
485    if after[0].starts_with('@') {
486        // Scoped package: @scope/pkg
487        if after.len() >= 2 {
488            Some(format!("{}/{}", after[0], after[1]))
489        } else {
490            Some(after[0].to_string())
491        }
492    } else {
493        Some(after[0].to_string())
494    }
495}
496
497/// Try to map a pnpm virtual store path back to a workspace source file.
498///
499/// When pnpm uses injected dependencies or certain linking strategies, canonical
500/// paths go through `.pnpm`:
501///   `/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/index.js`
502///
503/// This function detects such paths, extracts the package name, checks if it
504/// matches a workspace package, and tries to find the source file in that workspace.
505pub(super) fn try_pnpm_workspace_fallback(
506    path: &Path,
507    path_to_id: &FxHashMap<&Path, FileId>,
508    workspace_roots: &FxHashMap<&str, &Path>,
509) -> Option<FileId> {
510    // Only relevant for paths containing .pnpm
511    let components: Vec<&str> = path
512        .components()
513        .filter_map(|c| match c {
514            std::path::Component::Normal(s) => s.to_str(),
515            _ => None,
516        })
517        .collect();
518
519    // Find .pnpm component
520    let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
521
522    // After .pnpm, find the inner node_modules (the actual package location)
523    // Structure: .pnpm/<name>@<version>/node_modules/<package>/...
524    let after_pnpm = &components[pnpm_idx + 1..];
525
526    // Find "node_modules" inside the .pnpm directory
527    let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
528    let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
529
530    if after_inner_nm.is_empty() {
531        return None;
532    }
533
534    // Extract package name (handle scoped packages)
535    let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
536        if after_inner_nm.len() >= 2 {
537            (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
538        } else {
539            return None;
540        }
541    } else {
542        (after_inner_nm[0].to_string(), 1)
543    };
544
545    // Check if this package is a workspace package
546    let ws_root = workspace_roots.get(pkg_name.as_str())?;
547
548    // Get the relative path within the package (after the package name components)
549    let relative_parts = &after_inner_nm[pkg_name_components..];
550    if relative_parts.is_empty() {
551        return None;
552    }
553
554    let relative_path: PathBuf = relative_parts.iter().collect();
555
556    // Try direct file lookup in workspace root
557    let direct = ws_root.join(&relative_path);
558    if let Some(&file_id) = path_to_id.get(direct.as_path()) {
559        return Some(file_id);
560    }
561
562    // Try source fallback (dist/ → src/ etc.) within the workspace
563    try_source_fallback(&direct, path_to_id)
564}
565
566/// Try to resolve a bare specifier as a workspace package reference.
567///
568/// When the specifier's package name matches a workspace package, resolve the
569/// subpath against that package's root directory directly instead of going
570/// through `node_modules`. Covers two cases:
571///
572/// 1. **Self-referencing package imports**: Node.js v12+ lets a package import
573///    itself via its own name (`import { X } from '@org/pkg/subentry'` from
574///    inside `@org/pkg`). Angular libraries built with `ng-packagr` rely on
575///    this to declare secondary entry points.
576/// 2. **Cross-workspace imports without `node_modules` symlinks**: monorepos
577///    that have not been installed yet, or bundlers that bypass `node_modules`
578///    entirely, still need to resolve `@org/other-pkg/sub` to the sibling
579///    workspace's source file.
580///
581/// Strategy: strip the package name prefix and resolve the remainder as a
582/// relative path from inside the workspace root, so `oxc_resolver` applies
583/// directory indices, source extensions, and any workspace-local `tsconfig.json`
584/// path aliases. The `exports` field is intentionally bypassed — it points at
585/// compiled output (`dist/esm/button/index.js`) that does not exist in a
586/// source-only workspace.
587///
588/// See issue #106.
589pub(super) fn try_workspace_package_fallback(
590    ctx: &ResolveContext<'_>,
591    specifier: &str,
592) -> Option<ResolveResult> {
593    // Must look like a bare package specifier to avoid matching `./button`, etc.
594    if !super::path_info::is_bare_specifier(specifier) {
595        return None;
596    }
597    let pkg_name = super::path_info::extract_package_name(specifier);
598    let ws_root = *ctx.workspace_roots.get(pkg_name.as_str())?;
599
600    // Remainder after the package name. Empty for `@org/pkg`, `"button"` for
601    // `@org/pkg/button`, `"internal/base"` for `@org/pkg/internal/base`.
602    let subpath = specifier
603        .strip_prefix(pkg_name.as_str())
604        .and_then(|s| s.strip_prefix('/'))
605        .unwrap_or("");
606
607    // Synthetic importer inside the workspace root so tsconfig discovery walks
608    // up from the correct directory and relative specifiers anchor there.
609    let root_file = ws_root.join("__fallow_ws_self_resolve__");
610    let rel_spec = if subpath.is_empty() {
611        "./".to_string()
612    } else {
613        format!("./{subpath}")
614    };
615
616    let resolved = ctx.resolver.resolve_file(&root_file, &rel_spec).ok()?;
617    let resolved_path = resolved.path();
618
619    if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
620        return Some(ResolveResult::InternalModule(file_id));
621    }
622    if let Ok(canonical) = dunce::canonicalize(resolved_path) {
623        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
624            return Some(ResolveResult::InternalModule(file_id));
625        }
626        if let Some(fallback) = ctx.canonical_fallback
627            && let Some(file_id) = fallback.get(&canonical)
628        {
629            return Some(ResolveResult::InternalModule(file_id));
630        }
631        if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
632            return Some(ResolveResult::InternalModule(file_id));
633        }
634    }
635    None
636}
637
638/// Convert a `DynamicImportPattern` to a glob string for file matching.
639pub(super) fn make_glob_from_pattern(
640    pattern: &fallow_types::extract::DynamicImportPattern,
641) -> String {
642    // If the prefix already contains glob characters (from import.meta.glob), use as-is
643    if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
644        return pattern.prefix.clone();
645    }
646    pattern.suffix.as_ref().map_or_else(
647        || format!("{}*", pattern.prefix),
648        |suffix| format!("{}*{}", pattern.prefix, suffix),
649    )
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    #[test]
657    fn test_extract_package_name_from_node_modules_path_regular() {
658        let path = PathBuf::from("/project/node_modules/react/index.js");
659        assert_eq!(
660            extract_package_name_from_node_modules_path(&path),
661            Some("react".to_string())
662        );
663    }
664
665    #[test]
666    fn test_extract_package_name_from_node_modules_path_scoped() {
667        let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
668        assert_eq!(
669            extract_package_name_from_node_modules_path(&path),
670            Some("@babel/core".to_string())
671        );
672    }
673
674    #[test]
675    fn test_extract_package_name_from_node_modules_path_nested() {
676        // Nested node_modules: should use the last (innermost) one
677        let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
678        assert_eq!(
679            extract_package_name_from_node_modules_path(&path),
680            Some("pkg-b".to_string())
681        );
682    }
683
684    #[test]
685    fn test_extract_package_name_from_node_modules_path_deep_subpath() {
686        let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
687        assert_eq!(
688            extract_package_name_from_node_modules_path(&path),
689            Some("react-dom".to_string())
690        );
691    }
692
693    #[test]
694    fn test_extract_package_name_from_node_modules_path_no_node_modules() {
695        let path = PathBuf::from("/project/src/components/Button.tsx");
696        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
697    }
698
699    #[test]
700    fn test_extract_package_name_from_node_modules_path_just_node_modules() {
701        let path = PathBuf::from("/project/node_modules");
702        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
703    }
704
705    #[test]
706    fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
707        // Edge case: path ends at scope without package name
708        let path = PathBuf::from("/project/node_modules/@scope");
709        assert_eq!(
710            extract_package_name_from_node_modules_path(&path),
711            Some("@scope".to_string())
712        );
713    }
714
715    #[test]
716    fn test_resolve_specifier_node_modules_returns_npm_package() {
717        // When oxc_resolver resolves to a node_modules path that is NOT in path_to_id,
718        // it should return NpmPackage instead of ExternalFile.
719        // We can't easily test resolve_specifier directly without a real resolver,
720        // but the extract_package_name_from_node_modules_path function covers the
721        // core logic that was missing.
722        let path =
723            PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
724        assert_eq!(
725            extract_package_name_from_node_modules_path(&path),
726            Some("styled-components".to_string())
727        );
728
729        let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
730        assert_eq!(
731            extract_package_name_from_node_modules_path(&path),
732            Some("next".to_string())
733        );
734    }
735
736    #[test]
737    fn test_try_source_fallback_dist_to_src() {
738        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
739        let mut path_to_id = FxHashMap::default();
740        path_to_id.insert(src_path.as_path(), FileId(0));
741
742        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
743        assert_eq!(
744            try_source_fallback(&dist_path, &path_to_id),
745            Some(FileId(0)),
746            "dist/utils.js should fall back to src/utils.ts"
747        );
748    }
749
750    #[test]
751    fn test_try_source_fallback_build_to_src() {
752        let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
753        let mut path_to_id = FxHashMap::default();
754        path_to_id.insert(src_path.as_path(), FileId(1));
755
756        let build_path = PathBuf::from("/project/packages/core/build/index.js");
757        assert_eq!(
758            try_source_fallback(&build_path, &path_to_id),
759            Some(FileId(1)),
760            "build/index.js should fall back to src/index.tsx"
761        );
762    }
763
764    #[test]
765    fn test_try_source_fallback_no_match() {
766        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
767
768        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
769        assert_eq!(
770            try_source_fallback(&dist_path, &path_to_id),
771            None,
772            "should return None when no source file exists"
773        );
774    }
775
776    #[test]
777    fn test_try_source_fallback_non_output_dir() {
778        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
779        let mut path_to_id = FxHashMap::default();
780        path_to_id.insert(src_path.as_path(), FileId(0));
781
782        // A path that's not in an output directory should not trigger fallback
783        let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
784        assert_eq!(
785            try_source_fallback(&normal_path, &path_to_id),
786            None,
787            "non-output directory path should not trigger fallback"
788        );
789    }
790
791    #[test]
792    fn test_try_source_fallback_nested_path() {
793        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
794        let mut path_to_id = FxHashMap::default();
795        path_to_id.insert(src_path.as_path(), FileId(2));
796
797        let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
798        assert_eq!(
799            try_source_fallback(&dist_path, &path_to_id),
800            Some(FileId(2)),
801            "nested dist path should fall back to nested src path"
802        );
803    }
804
805    #[test]
806    fn test_try_source_fallback_nested_dist_esm() {
807        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
808        let mut path_to_id = FxHashMap::default();
809        path_to_id.insert(src_path.as_path(), FileId(0));
810
811        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
812        assert_eq!(
813            try_source_fallback(&dist_path, &path_to_id),
814            Some(FileId(0)),
815            "dist/esm/utils.mjs should fall back to src/utils.ts"
816        );
817    }
818
819    #[test]
820    fn test_try_source_fallback_nested_build_cjs() {
821        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
822        let mut path_to_id = FxHashMap::default();
823        path_to_id.insert(src_path.as_path(), FileId(1));
824
825        let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
826        assert_eq!(
827            try_source_fallback(&build_path, &path_to_id),
828            Some(FileId(1)),
829            "build/cjs/index.cjs should fall back to src/index.ts"
830        );
831    }
832
833    #[test]
834    fn test_try_source_fallback_nested_dist_esm_deep_path() {
835        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
836        let mut path_to_id = FxHashMap::default();
837        path_to_id.insert(src_path.as_path(), FileId(2));
838
839        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
840        assert_eq!(
841            try_source_fallback(&dist_path, &path_to_id),
842            Some(FileId(2)),
843            "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
844        );
845    }
846
847    #[test]
848    fn test_try_source_fallback_triple_nested_output_dirs() {
849        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
850        let mut path_to_id = FxHashMap::default();
851        path_to_id.insert(src_path.as_path(), FileId(0));
852
853        let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
854        assert_eq!(
855            try_source_fallback(&dist_path, &path_to_id),
856            Some(FileId(0)),
857            "out/dist/esm/utils.mjs should fall back to src/utils.ts"
858        );
859    }
860
861    #[test]
862    fn test_try_source_fallback_parent_dir_named_build() {
863        let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
864        let mut path_to_id = FxHashMap::default();
865        path_to_id.insert(src_path.as_path(), FileId(0));
866
867        let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
868        assert_eq!(
869            try_source_fallback(&dist_path, &path_to_id),
870            Some(FileId(0)),
871            "should resolve dist/ within project, not match parent 'build' dir"
872        );
873    }
874
875    #[test]
876    fn test_pnpm_store_path_extract_package_name() {
877        // pnpm virtual store paths should correctly extract package name
878        let path =
879            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
880        assert_eq!(
881            extract_package_name_from_node_modules_path(&path),
882            Some("react".to_string())
883        );
884    }
885
886    #[test]
887    fn test_pnpm_store_path_scoped_package() {
888        let path = PathBuf::from(
889            "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
890        );
891        assert_eq!(
892            extract_package_name_from_node_modules_path(&path),
893            Some("@babel/core".to_string())
894        );
895    }
896
897    #[test]
898    fn test_pnpm_store_path_with_peer_deps() {
899        let path = PathBuf::from(
900            "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
901        );
902        assert_eq!(
903            extract_package_name_from_node_modules_path(&path),
904            Some("webpack".to_string())
905        );
906    }
907
908    #[test]
909    fn test_try_pnpm_workspace_fallback_dist_to_src() {
910        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
911        let mut path_to_id = FxHashMap::default();
912        path_to_id.insert(src_path.as_path(), FileId(0));
913
914        let mut workspace_roots = FxHashMap::default();
915        let ws_root = PathBuf::from("/project/packages/ui");
916        workspace_roots.insert("@myorg/ui", ws_root.as_path());
917
918        // pnpm virtual store path with dist/ output
919        let pnpm_path = PathBuf::from(
920            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
921        );
922        assert_eq!(
923            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
924            Some(FileId(0)),
925            ".pnpm workspace path should fall back to src/utils.ts"
926        );
927    }
928
929    #[test]
930    fn test_try_pnpm_workspace_fallback_direct_source() {
931        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
932        let mut path_to_id = FxHashMap::default();
933        path_to_id.insert(src_path.as_path(), FileId(1));
934
935        let mut workspace_roots = FxHashMap::default();
936        let ws_root = PathBuf::from("/project/packages/core");
937        workspace_roots.insert("@myorg/core", ws_root.as_path());
938
939        // pnpm path pointing directly to src/
940        let pnpm_path = PathBuf::from(
941            "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
942        );
943        assert_eq!(
944            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
945            Some(FileId(1)),
946            ".pnpm workspace path with src/ should resolve directly"
947        );
948    }
949
950    #[test]
951    fn test_try_pnpm_workspace_fallback_non_workspace_package() {
952        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
953
954        let mut workspace_roots = FxHashMap::default();
955        let ws_root = PathBuf::from("/project/packages/ui");
956        workspace_roots.insert("@myorg/ui", ws_root.as_path());
957
958        // External package (not a workspace) — should return None
959        let pnpm_path =
960            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
961        assert_eq!(
962            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
963            None,
964            "non-workspace package in .pnpm should return None"
965        );
966    }
967
968    #[test]
969    fn test_try_pnpm_workspace_fallback_unscoped_package() {
970        let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
971        let mut path_to_id = FxHashMap::default();
972        path_to_id.insert(src_path.as_path(), FileId(2));
973
974        let mut workspace_roots = FxHashMap::default();
975        let ws_root = PathBuf::from("/project/packages/utils");
976        workspace_roots.insert("my-utils", ws_root.as_path());
977
978        // Unscoped workspace package in pnpm store
979        let pnpm_path = PathBuf::from(
980            "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
981        );
982        assert_eq!(
983            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
984            Some(FileId(2)),
985            "unscoped workspace package in .pnpm should resolve"
986        );
987    }
988
989    #[test]
990    fn test_try_pnpm_workspace_fallback_nested_path() {
991        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
992        let mut path_to_id = FxHashMap::default();
993        path_to_id.insert(src_path.as_path(), FileId(3));
994
995        let mut workspace_roots = FxHashMap::default();
996        let ws_root = PathBuf::from("/project/packages/ui");
997        workspace_roots.insert("@myorg/ui", ws_root.as_path());
998
999        // Nested path within the package
1000        let pnpm_path = PathBuf::from(
1001            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1002        );
1003        assert_eq!(
1004            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1005            Some(FileId(3)),
1006            "nested .pnpm workspace path should resolve through source fallback"
1007        );
1008    }
1009
1010    #[test]
1011    fn test_try_pnpm_workspace_fallback_no_pnpm() {
1012        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1013        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1014
1015        // Regular path without .pnpm — should return None immediately
1016        let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1017        assert_eq!(
1018            try_pnpm_workspace_fallback(&regular_path, &path_to_id, &workspace_roots),
1019            None,
1020        );
1021    }
1022
1023    #[test]
1024    fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1025        let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1026        let mut path_to_id = FxHashMap::default();
1027        path_to_id.insert(src_path.as_path(), FileId(4));
1028
1029        let mut workspace_roots = FxHashMap::default();
1030        let ws_root = PathBuf::from("/project/packages/ui");
1031        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1032
1033        // pnpm path with peer dependency suffix
1034        let pnpm_path = PathBuf::from(
1035            "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1036        );
1037        assert_eq!(
1038            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1039            Some(FileId(4)),
1040            ".pnpm path with peer dep suffix should still resolve"
1041        );
1042    }
1043
1044    // ── make_glob_from_pattern ───────────────────────────────────────
1045
1046    #[test]
1047    fn make_glob_prefix_only_no_suffix() {
1048        let pattern = fallow_types::extract::DynamicImportPattern {
1049            prefix: "./locales/".to_string(),
1050            suffix: None,
1051            span: oxc_span::Span::default(),
1052        };
1053        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*");
1054    }
1055
1056    #[test]
1057    fn make_glob_prefix_with_suffix() {
1058        let pattern = fallow_types::extract::DynamicImportPattern {
1059            prefix: "./locales/".to_string(),
1060            suffix: Some(".json".to_string()),
1061            span: oxc_span::Span::default(),
1062        };
1063        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1064    }
1065
1066    #[test]
1067    fn make_glob_passthrough_star() {
1068        // Prefix already contains glob characters — use as-is
1069        let pattern = fallow_types::extract::DynamicImportPattern {
1070            prefix: "./pages/**/*.tsx".to_string(),
1071            suffix: None,
1072            span: oxc_span::Span::default(),
1073        };
1074        assert_eq!(make_glob_from_pattern(&pattern), "./pages/**/*.tsx");
1075    }
1076
1077    #[test]
1078    fn make_glob_passthrough_brace() {
1079        let pattern = fallow_types::extract::DynamicImportPattern {
1080            prefix: "./i18n/{en,de,fr}.json".to_string(),
1081            suffix: None,
1082            span: oxc_span::Span::default(),
1083        };
1084        assert_eq!(make_glob_from_pattern(&pattern), "./i18n/{en,de,fr}.json");
1085    }
1086
1087    #[test]
1088    fn make_glob_empty_prefix_no_suffix() {
1089        let pattern = fallow_types::extract::DynamicImportPattern {
1090            prefix: String::new(),
1091            suffix: None,
1092            span: oxc_span::Span::default(),
1093        };
1094        assert_eq!(make_glob_from_pattern(&pattern), "*");
1095    }
1096
1097    #[test]
1098    fn make_glob_empty_prefix_with_suffix() {
1099        let pattern = fallow_types::extract::DynamicImportPattern {
1100            prefix: String::new(),
1101            suffix: Some(".ts".to_string()),
1102            span: oxc_span::Span::default(),
1103        };
1104        assert_eq!(make_glob_from_pattern(&pattern), "*.ts");
1105    }
1106
1107    // ── make_glob_from_pattern: template literal patterns ──────────
1108
1109    #[test]
1110    fn make_glob_template_literal_prefix_only() {
1111        // `./pages/${page}` extracts prefix="./pages/", suffix=None
1112        let pattern = fallow_types::extract::DynamicImportPattern {
1113            prefix: "./pages/".to_string(),
1114            suffix: None,
1115            span: oxc_span::Span::default(),
1116        };
1117        assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1118    }
1119
1120    #[test]
1121    fn make_glob_template_literal_with_extension_suffix() {
1122        // `./locales/${lang}.json` extracts prefix="./locales/", suffix=".json"
1123        let pattern = fallow_types::extract::DynamicImportPattern {
1124            prefix: "./locales/".to_string(),
1125            suffix: Some(".json".to_string()),
1126            span: oxc_span::Span::default(),
1127        };
1128        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
1129    }
1130
1131    #[test]
1132    fn make_glob_template_literal_deep_prefix() {
1133        // `./modules/${area}/components/${name}.tsx`
1134        // Extractor captures prefix="./modules/", suffix=None (only first dynamic part)
1135        let pattern = fallow_types::extract::DynamicImportPattern {
1136            prefix: "./modules/".to_string(),
1137            suffix: None,
1138            span: oxc_span::Span::default(),
1139        };
1140        assert_eq!(make_glob_from_pattern(&pattern), "./modules/*");
1141    }
1142
1143    #[test]
1144    fn make_glob_string_concat_prefix() {
1145        // `'./pages/' + name` extracts prefix="./pages/", suffix=None
1146        let pattern = fallow_types::extract::DynamicImportPattern {
1147            prefix: "./pages/".to_string(),
1148            suffix: None,
1149            span: oxc_span::Span::default(),
1150        };
1151        assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
1152    }
1153
1154    #[test]
1155    fn make_glob_string_concat_with_extension() {
1156        // `'./views/' + name + '.vue'` extracts prefix="./views/", suffix=".vue"
1157        let pattern = fallow_types::extract::DynamicImportPattern {
1158            prefix: "./views/".to_string(),
1159            suffix: Some(".vue".to_string()),
1160            span: oxc_span::Span::default(),
1161        };
1162        assert_eq!(make_glob_from_pattern(&pattern), "./views/*.vue");
1163    }
1164
1165    // ── make_glob_from_pattern: import.meta.glob ──────────────────
1166
1167    #[test]
1168    fn make_glob_import_meta_glob_recursive() {
1169        // import.meta.glob('./components/**/*.vue')
1170        let pattern = fallow_types::extract::DynamicImportPattern {
1171            prefix: "./components/**/*.vue".to_string(),
1172            suffix: None,
1173            span: oxc_span::Span::default(),
1174        };
1175        assert_eq!(
1176            make_glob_from_pattern(&pattern),
1177            "./components/**/*.vue",
1178            "import.meta.glob patterns with * should pass through as-is"
1179        );
1180    }
1181
1182    #[test]
1183    fn make_glob_import_meta_glob_brace_expansion() {
1184        // import.meta.glob('./plugins/{auth,analytics}.ts')
1185        let pattern = fallow_types::extract::DynamicImportPattern {
1186            prefix: "./plugins/{auth,analytics}.ts".to_string(),
1187            suffix: None,
1188            span: oxc_span::Span::default(),
1189        };
1190        assert_eq!(
1191            make_glob_from_pattern(&pattern),
1192            "./plugins/{auth,analytics}.ts",
1193            "import.meta.glob patterns with braces should pass through as-is"
1194        );
1195    }
1196
1197    #[test]
1198    fn make_glob_import_meta_glob_star_with_brace() {
1199        // import.meta.glob('./routes/**/*.{ts,tsx}')
1200        let pattern = fallow_types::extract::DynamicImportPattern {
1201            prefix: "./routes/**/*.{ts,tsx}".to_string(),
1202            suffix: None,
1203            span: oxc_span::Span::default(),
1204        };
1205        assert_eq!(
1206            make_glob_from_pattern(&pattern),
1207            "./routes/**/*.{ts,tsx}",
1208            "combined * and brace patterns should pass through"
1209        );
1210    }
1211
1212    #[test]
1213    fn make_glob_import_meta_glob_ignores_suffix_when_star_present() {
1214        // Edge case: prefix contains *, suffix is provided (unlikely but defensive)
1215        let pattern = fallow_types::extract::DynamicImportPattern {
1216            prefix: "./*.ts".to_string(),
1217            suffix: Some(".extra".to_string()),
1218            span: oxc_span::Span::default(),
1219        };
1220        assert_eq!(
1221            make_glob_from_pattern(&pattern),
1222            "./*.ts",
1223            "when prefix has glob chars, suffix is ignored (prefix used as-is)"
1224        );
1225    }
1226
1227    // ── make_glob_from_pattern: edge cases ────────────────────────
1228
1229    #[test]
1230    fn make_glob_single_dot_prefix() {
1231        let pattern = fallow_types::extract::DynamicImportPattern {
1232            prefix: "./".to_string(),
1233            suffix: None,
1234            span: oxc_span::Span::default(),
1235        };
1236        assert_eq!(make_glob_from_pattern(&pattern), "./*");
1237    }
1238
1239    #[test]
1240    fn make_glob_prefix_without_trailing_slash() {
1241        // `'./config' + ext` -> prefix="./config", suffix might be extension
1242        let pattern = fallow_types::extract::DynamicImportPattern {
1243            prefix: "./config".to_string(),
1244            suffix: None,
1245            span: oxc_span::Span::default(),
1246        };
1247        assert_eq!(make_glob_from_pattern(&pattern), "./config*");
1248    }
1249
1250    #[test]
1251    fn make_glob_prefix_with_dotdot() {
1252        let pattern = fallow_types::extract::DynamicImportPattern {
1253            prefix: "../shared/".to_string(),
1254            suffix: Some(".ts".to_string()),
1255            span: oxc_span::Span::default(),
1256        };
1257        assert_eq!(make_glob_from_pattern(&pattern), "../shared/*.ts");
1258    }
1259
1260    // ── extract_package_name: additional edge cases ───────────────
1261
1262    #[test]
1263    fn test_extract_package_name_with_pnpm_plus_encoded_scope() {
1264        // pnpm encodes @scope/pkg as @scope+pkg in store path
1265        // but the inner node_modules still uses the real scope
1266        let path = PathBuf::from(
1267            "/project/node_modules/.pnpm/@mui+material@5.15.0/node_modules/@mui/material/index.js",
1268        );
1269        assert_eq!(
1270            extract_package_name_from_node_modules_path(&path),
1271            Some("@mui/material".to_string())
1272        );
1273    }
1274
1275    #[test]
1276    fn test_extract_package_name_windows_style_path() {
1277        // Windows-style paths should still work since we filter for Normal components
1278        let path = PathBuf::from("/project/node_modules/typescript/lib/tsc.js");
1279        assert_eq!(
1280            extract_package_name_from_node_modules_path(&path),
1281            Some("typescript".to_string())
1282        );
1283    }
1284
1285    // ── try_source_fallback: additional output dir patterns ───────
1286
1287    #[test]
1288    fn test_try_source_fallback_out_dir() {
1289        let src_path = PathBuf::from("/project/packages/api/src/handler.ts");
1290        let mut path_to_id = FxHashMap::default();
1291        path_to_id.insert(src_path.as_path(), FileId(5));
1292
1293        let out_path = PathBuf::from("/project/packages/api/out/handler.js");
1294        assert_eq!(
1295            try_source_fallback(&out_path, &path_to_id),
1296            Some(FileId(5)),
1297            "out/handler.js should fall back to src/handler.ts"
1298        );
1299    }
1300
1301    #[test]
1302    fn test_try_source_fallback_mts_extension() {
1303        let src_path = PathBuf::from("/project/packages/lib/src/utils.mts");
1304        let mut path_to_id = FxHashMap::default();
1305        path_to_id.insert(src_path.as_path(), FileId(6));
1306
1307        let dist_path = PathBuf::from("/project/packages/lib/dist/utils.mjs");
1308        assert_eq!(
1309            try_source_fallback(&dist_path, &path_to_id),
1310            Some(FileId(6)),
1311            "dist/utils.mjs should fall back to src/utils.mts"
1312        );
1313    }
1314
1315    #[test]
1316    fn test_try_source_fallback_cts_extension() {
1317        let src_path = PathBuf::from("/project/packages/lib/src/config.cts");
1318        let mut path_to_id = FxHashMap::default();
1319        path_to_id.insert(src_path.as_path(), FileId(7));
1320
1321        let dist_path = PathBuf::from("/project/packages/lib/dist/config.cjs");
1322        assert_eq!(
1323            try_source_fallback(&dist_path, &path_to_id),
1324            Some(FileId(7)),
1325            "dist/config.cjs should fall back to src/config.cts"
1326        );
1327    }
1328
1329    #[test]
1330    fn test_try_source_fallback_jsx_extension() {
1331        let src_path = PathBuf::from("/project/packages/ui/src/App.jsx");
1332        let mut path_to_id = FxHashMap::default();
1333        path_to_id.insert(src_path.as_path(), FileId(8));
1334
1335        let build_path = PathBuf::from("/project/packages/ui/build/App.js");
1336        assert_eq!(
1337            try_source_fallback(&build_path, &path_to_id),
1338            Some(FileId(8)),
1339            "build/App.js should fall back to src/App.jsx"
1340        );
1341    }
1342
1343    #[test]
1344    fn test_try_source_fallback_no_file_stem() {
1345        // Path with no filename at all should return None gracefully
1346        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1347        let dist_path = PathBuf::from("/project/packages/ui/dist/");
1348        assert_eq!(
1349            try_source_fallback(&dist_path, &path_to_id),
1350            None,
1351            "directory path with no file should return None"
1352        );
1353    }
1354
1355    #[test]
1356    fn test_try_source_fallback_esm_subdir() {
1357        // esm is an output directory, so dist/esm -> src
1358        let src_path = PathBuf::from("/project/lib/src/index.ts");
1359        let mut path_to_id = FxHashMap::default();
1360        path_to_id.insert(src_path.as_path(), FileId(10));
1361
1362        let dist_path = PathBuf::from("/project/lib/esm/index.mjs");
1363        assert_eq!(
1364            try_source_fallback(&dist_path, &path_to_id),
1365            Some(FileId(10)),
1366            "standalone esm/ directory should fall back to src/"
1367        );
1368    }
1369
1370    #[test]
1371    fn test_try_source_fallback_cjs_subdir() {
1372        let src_path = PathBuf::from("/project/lib/src/index.ts");
1373        let mut path_to_id = FxHashMap::default();
1374        path_to_id.insert(src_path.as_path(), FileId(11));
1375
1376        let cjs_path = PathBuf::from("/project/lib/cjs/index.cjs");
1377        assert_eq!(
1378            try_source_fallback(&cjs_path, &path_to_id),
1379            Some(FileId(11)),
1380            "standalone cjs/ directory should fall back to src/"
1381        );
1382    }
1383
1384    // ── try_pnpm_workspace_fallback: edge cases ──────────────────
1385
1386    #[test]
1387    fn test_try_pnpm_workspace_fallback_empty_after_pnpm() {
1388        // Path that has .pnpm but nothing after the inner node_modules
1389        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1390        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1391
1392        let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/node_modules");
1393        assert_eq!(
1394            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1395            None,
1396            "path ending at node_modules with nothing after should return None"
1397        );
1398    }
1399
1400    #[test]
1401    fn test_try_pnpm_workspace_fallback_scoped_package_only_scope() {
1402        // Path has .pnpm/inner-node_modules/@scope but no package name after scope
1403        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1404        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1405
1406        let pnpm_path =
1407            PathBuf::from("/project/node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope");
1408        assert_eq!(
1409            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1410            None,
1411            "scoped package without full name and no matching workspace should return None"
1412        );
1413    }
1414
1415    #[test]
1416    fn test_try_pnpm_workspace_fallback_no_inner_node_modules() {
1417        // Path has .pnpm but no inner node_modules
1418        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1419        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1420
1421        let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/dist/index.js");
1422        assert_eq!(
1423            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1424            None,
1425            "path without inner node_modules after .pnpm should return None"
1426        );
1427    }
1428
1429    #[test]
1430    fn test_try_pnpm_workspace_fallback_package_without_relative_path() {
1431        // Path ends right at the package name, no file path after it
1432        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1433        let mut workspace_roots = FxHashMap::default();
1434        let ws_root = PathBuf::from("/project/packages/ui");
1435        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1436
1437        let pnpm_path =
1438            PathBuf::from("/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui");
1439        assert_eq!(
1440            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1441            None,
1442            "path ending at package name with no relative file should return None"
1443        );
1444    }
1445
1446    #[test]
1447    fn test_try_pnpm_workspace_fallback_nested_dist_esm() {
1448        let src_path = PathBuf::from("/project/packages/ui/src/Button.ts");
1449        let mut path_to_id = FxHashMap::default();
1450        path_to_id.insert(src_path.as_path(), FileId(10));
1451
1452        let mut workspace_roots = FxHashMap::default();
1453        let ws_root = PathBuf::from("/project/packages/ui");
1454        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1455
1456        // Nested output dirs within pnpm workspace path
1457        let pnpm_path = PathBuf::from(
1458            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/esm/Button.mjs",
1459        );
1460        assert_eq!(
1461            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1462            Some(FileId(10)),
1463            "pnpm path with nested dist/esm should resolve through source fallback"
1464        );
1465    }
1466}