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