Skip to main content

fallow_graph/resolve/
fallbacks.rs

1//! Resolution fallback strategies for import specifiers.
2//!
3//! Handles path alias fallbacks, output-to-source directory mapping, pnpm virtual
4//! store detection, node_modules package extraction, and dynamic import glob patterns.
5
6use std::path::{Path, PathBuf};
7
8use rustc_hash::FxHashMap;
9use serde_json::Value;
10
11use fallow_types::discover::FileId;
12
13use super::path_info::{extract_package_name, is_bare_specifier, is_valid_package_name};
14use super::types::{
15    MISSING_ONLY_OUTPUT_DIRS, OUTPUT_DIRS, PackageManifestInfo, ResolveContext, ResolveResult,
16    SOURCE_EXTS,
17};
18
19#[path = "fallbacks_relative_package_root.rs"]
20mod relative_package_root;
21#[path = "fallbacks_scss_paths.rs"]
22mod scss_paths;
23#[path = "fallbacks_tsconfig_output.rs"]
24mod tsconfig_output;
25#[path = "fallbacks_workspace_package_asset.rs"]
26mod workspace_package_asset;
27
28pub(super) use relative_package_root::try_relative_package_root_source_fallback;
29use scss_paths::{find_scss_through_path_alias, lookup_scss_path};
30use tsconfig_output::try_tsconfig_output_source;
31pub(super) use tsconfig_output::{
32    try_canonical_source_fallbacks, try_relative_tsconfig_output_fallback,
33};
34use workspace_package_asset::{exports_target_asset, workspace_package_asset};
35
36/// Return the post-prefix remainder when `specifier` matches the alias `prefix`
37/// at a path boundary, else `None`.
38///
39/// The match is segment-aware: a bare exact-key alias (e.g. `@scope/sdk` or
40/// `vscode`) matches only on an exact hit or a `/`-delimited continuation, so it
41/// never captures a longer package that merely shares the prefix
42/// (`@scope/sdk-extra`). Prefixes that already end in `/` (`~/`, `@/`, `$lib/`)
43/// match any continuation by construction, preserving their existing behavior.
44fn alias_match_remainder<'a>(specifier: &'a str, prefix: &str) -> Option<&'a str> {
45    let remainder = specifier.strip_prefix(prefix)?;
46    (remainder.is_empty() || prefix.ends_with('/') || remainder.starts_with('/'))
47        .then_some(remainder)
48}
49
50/// Try resolving a specifier using plugin-provided path aliases.
51///
52/// Substitutes a matching alias prefix (e.g., `~/`) with a directory relative to the
53/// project root (e.g., `app/`) and resolves the resulting path. This handles framework
54/// aliases like Nuxt's `~/`, `~~/`, `#shared/` that aren't defined in tsconfig.json
55/// but map to real filesystem paths.
56pub(super) fn try_path_alias_fallback(
57    ctx: &ResolveContext<'_>,
58    specifier: &str,
59    style_context: bool,
60) -> Option<ResolveResult> {
61    for (prefix, replacement) in ctx.path_aliases {
62        let Some(remainder) = alias_match_remainder(specifier, prefix) else {
63            continue;
64        };
65
66        let substituted = match (replacement.is_empty(), remainder.is_empty()) {
67            (true, _) => format!("./{remainder}"),
68            (false, true) => format!("./{replacement}"),
69            (false, false) => format!("./{replacement}/{remainder}"),
70        };
71
72        if let Ok(resolved) = super::specifier::resolve_with_extension_policy(
73            ctx,
74            ctx.root,
75            &substituted,
76            style_context,
77        ) {
78            let resolved_path = resolved.path();
79            if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
80                return Some(ResolveResult::InternalModule(file_id));
81            }
82            if let Some(canonical) = ctx.canonicalize_cache.get(resolved_path) {
83                if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
84                    return Some(ResolveResult::InternalModule(file_id));
85                }
86                if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
87                    return Some(ResolveResult::InternalModule(file_id));
88                }
89                if let Some(file_id) =
90                    try_pnpm_workspace_fallback(&canonical, ctx.path_to_id, ctx.workspace_roots)
91                {
92                    return Some(ResolveResult::InternalModule(file_id));
93                }
94                if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
95                    return Some(ResolveResult::NpmPackage(pkg_name));
96                }
97                return Some(ResolveResult::ExternalFile(canonical));
98            }
99        }
100    }
101    None
102}
103
104/// Try SCSS partial resolution: `_filename` and `_index` conventions.
105///
106/// SCSS resolves imports in this order:
107/// 1. `@use 'variables'` → `_variables.scss` (partial convention)
108/// 2. `@use 'components'` → `components/_index.scss` or `components/index.scss` (directory index)
109///
110/// Handles both relative (`../styles/variables`) and bare (`variables`) specifiers
111/// that were normalized to `./variables` during extraction.
112pub(super) fn try_scss_partial_fallback(
113    ctx: &ResolveContext<'_>,
114    from_file: &Path,
115    specifier: &str,
116) -> Option<ResolveResult> {
117    if specifier.contains(':') {
118        return None;
119    }
120
121    let spec_path = Path::new(specifier);
122    let filename = spec_path.file_name()?.to_str()?;
123
124    if filename.starts_with('_') {
125        return None;
126    }
127
128    let partial_filename = format!("_{filename}");
129    let partial_specifier = if let Some(parent) = spec_path.parent()
130        && !parent.as_os_str().is_empty()
131    {
132        format!("{}/{partial_filename}", parent.display())
133    } else {
134        partial_filename
135    };
136
137    if let Some(result) = try_resolve_scss(ctx, from_file, &partial_specifier) {
138        return Some(result);
139    }
140
141    let index_partial = format!("{specifier}/_index");
142    if let Some(result) = try_resolve_scss(ctx, from_file, &index_partial) {
143        return Some(result);
144    }
145
146    let index_plain = format!("{specifier}/index");
147    try_resolve_scss(ctx, from_file, &index_plain)
148}
149
150/// Try non-partial CSS-extension resolution: `<spec>.scss`, `<spec>.sass`,
151/// `<spec>.css` from the importing file's parent.
152///
153/// This is needed when the standard resolver's extension list contains both
154/// `.vue` / `.svelte` / `.astro` AND CSS extensions. For an SFC `<style>` block
155/// importing `./Foo`, the standard resolver picks `Foo.vue` (the SFC itself!)
156/// before `Foo.scss` because `.vue` comes earlier in the extension list. SCSS
157/// imports must restrict resolution to CSS-family extensions to avoid this
158/// self-import collision. Only invoked when `from_style = true`. See issue #195.
159pub(super) fn try_css_extension_fallback(
160    ctx: &ResolveContext<'_>,
161    from_file: &Path,
162    specifier: &str,
163) -> Option<ResolveResult> {
164    if specifier.contains(':') {
165        return None;
166    }
167    let spec_path = Path::new(specifier);
168    let already_css_ext = spec_path
169        .extension()
170        .and_then(|e| e.to_str())
171        .is_some_and(|e| {
172            e.eq_ignore_ascii_case("css")
173                || e.eq_ignore_ascii_case("scss")
174                || e.eq_ignore_ascii_case("sass")
175        });
176    if already_css_ext {
177        return try_resolve_scss(ctx, from_file, specifier);
178    }
179    for ext in ["scss", "sass", "css"] {
180        let candidate = format!("{specifier}.{ext}");
181        if let Some(result) = try_resolve_scss(ctx, from_file, &candidate) {
182            return Some(result);
183        }
184    }
185    None
186}
187
188/// Attempt to resolve a single SCSS specifier and map to an internal module.
189fn try_resolve_scss(
190    ctx: &ResolveContext<'_>,
191    from_file: &Path,
192    specifier: &str,
193) -> Option<ResolveResult> {
194    super::work::note_oxc_resolve();
195    let resolved = ctx.resolver.resolve_file(from_file, specifier).ok()?;
196    let resolved_path = resolved.path();
197
198    if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
199        return Some(ResolveResult::InternalModule(file_id));
200    }
201    if let Some(canonical) = ctx.canonicalize_cache.get(resolved_path)
202        && let Some(&file_id) = ctx.path_to_id.get(canonical.as_path())
203    {
204        return Some(ResolveResult::InternalModule(file_id));
205    }
206    None
207}
208
209/// Try SCSS `includePaths` fallback: resolve the specifier against each
210/// framework-contributed include directory.
211///
212/// Angular's `stylePreprocessorOptions.includePaths` (and Nx's equivalent via
213/// project.json) adds extra search paths that SCSS resolves against before
214/// falling back to node_modules. Bare `@use 'variables'` statements that were
215/// normalized to `./variables` at extraction time fail the usual file-local
216/// resolution, so when the importing file is `.scss`/`.sass` and the spec
217/// originated from such a bare specifier, we retry against each include path,
218/// applying the SCSS partial (`_variables`) and directory-index conventions.
219/// SFC `<style lang="scss">` imports pass `from_style = true` because their
220/// filesystem importer is `.vue` / `.svelte`, not `.scss` / `.sass`.
221///
222/// The specifier arrives with a `./` prefix because `normalize_css_import_path`
223/// rewrites bare extensionless SCSS specifiers to relative ones. We strip that
224/// prefix here to re-enter the include-path search from the root of each
225/// directory. Relative specifiers that already escape the importing file
226/// (e.g. `../shared/variables`) are left untouched — include paths only
227/// disambiguate bare specifiers, not explicit relative paths.
228pub(super) fn try_scss_include_path_fallback(
229    ctx: &ResolveContext<'_>,
230    from_file: &Path,
231    specifier: &str,
232    from_style: bool,
233) -> Option<ResolveResult> {
234    if ctx.scss_include_paths.is_empty() && ctx.path_aliases.is_empty() {
235        return None;
236    }
237    let is_scss_importer = from_file
238        .extension()
239        .is_some_and(|e| e == "scss" || e == "sass");
240    if !is_scss_importer && !from_style {
241        return None;
242    }
243    if specifier.contains(':') {
244        return None;
245    }
246    let bare = specifier.strip_prefix("./")?;
247    if bare.starts_with("..") || bare.starts_with('/') {
248        return None;
249    }
250
251    for include_dir in ctx.scss_include_paths {
252        if let Some(file_id) = find_scss_in_dir(include_dir, bare, ctx) {
253            return Some(ResolveResult::InternalModule(file_id));
254        }
255    }
256    find_scss_through_path_alias(ctx, bare).map(ResolveResult::InternalModule)
257}
258
259/// Probe an SCSS include directory for a bare specifier, applying the standard
260/// SCSS resolution order: exact file, `_`-prefixed partial, `_index` / `index`
261/// directory conventions. Supports `.scss` and `.sass` extensions.
262fn find_scss_in_dir(include_dir: &Path, bare: &str, ctx: &ResolveContext<'_>) -> Option<FileId> {
263    let bare_path = Path::new(bare);
264    let has_scss_ext = matches!(
265        bare_path.extension().and_then(|e| e.to_str()),
266        Some(ext) if ext.eq_ignore_ascii_case("scss") || ext.eq_ignore_ascii_case("sass")
267    );
268
269    let parent = bare_path.parent();
270    let stem_with_ext = bare_path.file_name()?.to_str()?;
271    let stem_without_ext = bare_path.file_stem().and_then(|s| s.to_str())?;
272
273    let build = |rel: &Path| -> std::path::PathBuf { include_dir.join(rel) };
274    let join_with_parent = |name: &str| -> std::path::PathBuf {
275        parent.map_or_else(|| build(Path::new(name)), |p| build(&p.join(name)))
276    };
277
278    let exts: &[&str] = if has_scss_ext {
279        &[""]
280    } else {
281        &["scss", "sass"]
282    };
283
284    for ext in exts {
285        let suffix = if ext.is_empty() {
286            String::new()
287        } else {
288            format!(".{ext}")
289        };
290        let direct = if ext.is_empty() {
291            build(bare_path)
292        } else {
293            join_with_parent(&format!("{stem_with_ext}{suffix}"))
294        };
295        if let Some(fid) = lookup_scss_path(&direct, ctx) {
296            return Some(fid);
297        }
298        let partial_name = if ext.is_empty() {
299            format!("_{stem_with_ext}")
300        } else {
301            format!("_{stem_without_ext}{suffix}")
302        };
303        let partial = join_with_parent(&partial_name);
304        if let Some(fid) = lookup_scss_path(&partial, ctx) {
305            return Some(fid);
306        }
307        if ext.is_empty() {
308            continue;
309        }
310        let idx_partial = build(bare_path).join(format!("_index{suffix}"));
311        if let Some(fid) = lookup_scss_path(&idx_partial, ctx) {
312            return Some(fid);
313        }
314        let idx_plain = build(bare_path).join(format!("index{suffix}"));
315        if let Some(fid) = lookup_scss_path(&idx_plain, ctx) {
316            return Some(fid);
317        }
318    }
319    None
320}
321
322/// Try SCSS `node_modules` fallback: resolve a bare specifier by walking up
323/// from the importing file and probing each ancestor's `node_modules/` dir.
324///
325/// Sass's `@import` / `@use` resolution algorithm searches `node_modules/` for
326/// bare specifiers after the file-local and `includePaths` searches fail.
327/// `@import 'bootstrap/scss/functions'` resolves to
328/// `node_modules/bootstrap/scss/_functions.scss` via the standard partial
329/// convention; `@import 'animate.css/animate.min'` resolves to
330/// `node_modules/animate.css/animate.min.css` via the CSS-extension fallback.
331///
332/// Files inside `node_modules/` are not in fallow's file index (the default
333/// ignore patterns exclude them), so this function returns
334/// `ResolveResult::NpmPackage` when a candidate exists on disk. That ensures
335/// (1) the `@import` is not reported as unresolved and (2) the npm package is
336/// marked as a used dependency so `unused-dependencies` / `unlisted-dependencies`
337/// stay accurate.
338///
339/// The specifier arrives with a `./` prefix because `normalize_css_import_path`
340/// rewrites bare extensionless SCSS specifiers to relative ones. Parent-relative
341/// specifiers are skipped — they explicitly escape the importing file and must
342/// not be silently redirected to `node_modules`. See issue #125.
343pub(super) fn try_scss_node_modules_fallback(
344    _ctx: &ResolveContext<'_>,
345    from_file: &Path,
346    specifier: &str,
347    from_style: bool,
348) -> Option<ResolveResult> {
349    if specifier.contains(':') {
350        return None;
351    }
352    let is_scss_importer = from_file
353        .extension()
354        .is_some_and(|e| e == "scss" || e == "sass");
355    if !is_scss_importer && !from_style {
356        return None;
357    }
358    let bare = specifier.strip_prefix("./")?;
359    if bare.starts_with("..") || bare.starts_with('/') {
360        return None;
361    }
362    if bare.is_empty() {
363        return None;
364    }
365
366    let mut dir = from_file.parent()?;
367    loop {
368        let nm_dir = dir.join("node_modules");
369        if nm_dir.is_dir()
370            && let Some(path) = find_scss_in_node_modules(&nm_dir, bare)
371            && let Some(pkg_name) = extract_package_name_from_node_modules_path(&path)
372        {
373            return Some(ResolveResult::NpmPackage(pkg_name));
374        }
375        let Some(parent) = dir.parent() else {
376            break;
377        };
378        dir = parent;
379    }
380    None
381}
382
383/// Probe candidate filesystem paths for a bare SCSS specifier inside a single
384/// `node_modules/` directory, applying Sass resolution conventions.
385///
386/// Candidate order:
387/// 1. `<bare>.scss` / `<bare>.sass` / `<bare>.css` (extension append)
388/// 2. `<parent>/_<stem>.scss` / `<parent>/_<stem>.sass` (partial convention)
389/// 3. `<bare>/_index.scss` / `<bare>/index.scss` (and `.sass` variants)
390/// 4. `<bare>` (exact, for specifiers that already carry an extension)
391fn find_scss_in_node_modules(nm_dir: &Path, bare: &str) -> Option<PathBuf> {
392    let bare_path = Path::new(bare);
393    let file_name = bare_path.file_name()?.to_str()?;
394    let parent = bare_path.parent();
395    let join_with_parent = |name: &str| -> PathBuf {
396        parent.map_or_else(|| nm_dir.join(name), |p| nm_dir.join(p).join(name))
397    };
398
399    for ext in &["scss", "sass", "css"] {
400        let candidate = join_with_parent(&format!("{file_name}.{ext}"));
401        if candidate.is_file() {
402            return Some(candidate);
403        }
404    }
405    for ext in &["scss", "sass"] {
406        let candidate = join_with_parent(&format!("_{file_name}.{ext}"));
407        if candidate.is_file() {
408            return Some(candidate);
409        }
410    }
411    for ext in &["scss", "sass"] {
412        let idx_partial = nm_dir.join(bare).join(format!("_index.{ext}"));
413        if idx_partial.is_file() {
414            return Some(idx_partial);
415        }
416        let idx_plain = nm_dir.join(bare).join(format!("index.{ext}"));
417        if idx_plain.is_file() {
418            return Some(idx_plain);
419        }
420    }
421    let exact = nm_dir.join(bare);
422    if exact.is_file() {
423        return Some(exact);
424    }
425    None
426}
427
428/// Try to map a resolved output path (e.g., `packages/ui/dist/utils.js`) back to
429/// the corresponding source file (e.g., `packages/ui/src/utils.ts`).
430///
431/// This handles cross-workspace imports that go through `exports` maps pointing to
432/// built output directories. Since fallow ignores `dist/`, `build/`, etc. by default,
433/// the resolved path won't be in the file set, but the source file will be.
434///
435/// Nested output subdirectories (e.g., `dist/esm/utils.mjs`, `build/cjs/index.cjs`)
436/// are handled by finding the last output directory component (closest to the file,
437/// avoiding false matches on parent directories) and then walking backwards to collect
438/// all consecutive output directory components before it.
439pub(super) fn try_source_fallback(
440    resolved: &Path,
441    path_to_id: &FxHashMap<&Path, FileId>,
442) -> Option<FileId> {
443    try_source_fallback_with(Path::new(""), resolved, path_to_id, &[OUTPUT_DIRS])
444}
445
446/// Map a package manifest target below `lib/` to `src/`.
447///
448/// Callers run this only after the target is absent from the file set and
449/// after [`try_source_fallback`] found no match. `lib` (from
450/// `MISSING_ONLY_OUTPUT_DIRS`) applies only to the path below `package_root`,
451/// so a package directory named `lib` does not change the result. Generic
452/// relative imports never use this function.
453fn try_missing_only_source_fallback(
454    package_root: &Path,
455    resolved: &Path,
456    path_to_id: &FxHashMap<&Path, FileId>,
457) -> Option<FileId> {
458    let relative = resolved.strip_prefix(package_root).ok()?;
459    try_source_fallback_with(
460        package_root,
461        relative,
462        path_to_id,
463        &[OUTPUT_DIRS, MISSING_ONLY_OUTPUT_DIRS],
464    )
465}
466
467/// Map the last output directory component of `relative` (any name in
468/// `dir_lists`) to `src/`, and look up the result below `base` in `path_to_id`.
469fn try_source_fallback_with(
470    base: &Path,
471    relative: &Path,
472    path_to_id: &FxHashMap<&Path, FileId>,
473    dir_lists: &[&[&str]],
474) -> Option<FileId> {
475    let components: Vec<_> = relative.components().collect();
476
477    let is_output_dir = |c: &std::path::Component| -> bool {
478        if let std::path::Component::Normal(s) = c
479            && let Some(name) = s.to_str()
480        {
481            return dir_lists.iter().any(|dirs| dirs.contains(&name));
482        }
483        false
484    };
485
486    let last_output_pos = components.iter().rposition(&is_output_dir)?;
487
488    let mut first_output_pos = last_output_pos;
489    while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
490        first_output_pos -= 1;
491    }
492
493    let relative_prefix: PathBuf = components[..first_output_pos].iter().collect();
494    let prefix = if base.as_os_str().is_empty() {
495        relative_prefix
496    } else {
497        base.join(relative_prefix)
498    };
499
500    let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
501    let file_name = suffix.file_name()?.to_str()?;
502    let stem = output_source_stem(file_name);
503    let source_dir = match suffix.parent() {
504        Some(parent) => prefix.join("src").join(parent),
505        None => prefix.join("src"),
506    };
507
508    for ext in SOURCE_EXTS {
509        let source_candidate = source_dir.join(format!("{stem}.{ext}"));
510        if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
511            return Some(file_id);
512        }
513    }
514
515    // A package can copy a hand-written declaration file from `src/` to the
516    // output directory unchanged.
517    if is_declaration_file_name(file_name) {
518        return path_to_id
519            .get(source_dir.join(file_name).as_path())
520            .copied();
521    }
522
523    None
524}
525
526/// Declaration suffixes that build tools emit next to the output files.
527const DECLARATION_SUFFIXES: &[&str] = &[".d.ts", ".d.mts", ".d.cts"];
528
529fn declaration_stem(file_name: &str) -> Option<&str> {
530    DECLARATION_SUFFIXES
531        .iter()
532        .find_map(|suffix| file_name.strip_suffix(suffix))
533        .filter(|stem| !stem.is_empty())
534}
535
536pub(super) fn is_declaration_file_name(file_name: &str) -> bool {
537    declaration_stem(file_name).is_some()
538}
539
540/// Return the source stem of a build output file name.
541///
542/// The stem keeps every dot in the base name. `feature.port.d.ts` and
543/// `feature.port.js` both give `feature.port`. `Path::with_extension` removes
544/// only the last extension, so it keeps `.d` in a declaration file name.
545pub(super) fn output_source_stem(file_name: &str) -> &str {
546    if let Some(stem) = declaration_stem(file_name) {
547        return stem;
548    }
549    match file_name.rsplit_once('.') {
550        Some((stem, _)) if !stem.is_empty() => stem,
551        _ => file_name,
552    }
553}
554
555/// Try to resolve a package `imports` entry from the nearest owning package.
556///
557/// `#...` specifiers are package-local by definition, so this fallback is only
558/// allowed when the importing file's nearest package manifest has a matching
559/// `imports` key. That keeps unrelated hash-prefixed path aliases unresolved.
560pub(super) fn try_package_imports_fallback(
561    ctx: &ResolveContext<'_>,
562    from_file: &Path,
563    specifier: &str,
564) -> Option<ResolveResult> {
565    if !specifier.starts_with('#') {
566        return None;
567    }
568    let manifest = nearest_package_manifest(ctx.package_manifests, from_file)?;
569    let imports = manifest.package_json.imports.as_ref()?;
570    let PackageMapTarget::Targets(targets) =
571        package_map_target(imports, specifier, ctx.condition_names)
572    else {
573        return None;
574    };
575    let source_subpath = package_import_source_subpath(manifest, specifier);
576    resolve_package_import_targets(ctx, manifest, &targets, source_subpath.as_deref()).map(
577        |target| match target {
578            PackageImportTarget::Internal(file_id) => match &manifest.name {
579                Some(package_name) => ResolveResult::InternalPackageModule {
580                    file_id,
581                    package_name: package_name.clone(),
582                },
583                None => ResolveResult::InternalModule(file_id),
584            },
585            PackageImportTarget::ExternalPackage(package_name) => {
586                ResolveResult::NpmPackage(package_name)
587            }
588        },
589    )
590}
591
592/// Return the workspace package that a package `imports` alias reached.
593///
594/// An alias such as `"#lib/*": "@acme/lib/*"` can resolve through the install
595/// symlink straight to the workspace source file. The file edge is correct, but
596/// the dependency on `@acme/lib` must still receive usage credit, the same as a
597/// direct `@acme/lib/...` import.
598///
599/// Only the package that owns `resolved_file` can receive the credit, and only
600/// when an alias target names it. Node.js resolves a fallback array such as
601/// `["./src/x.ts", "@acme/lib/x"]` to the local file, so `@acme/lib` receives
602/// no credit for it. A target that names a package outside the known package
603/// manifests also returns `None`.
604pub(super) fn package_imports_workspace_target(
605    ctx: &ResolveContext<'_>,
606    from_file: &Path,
607    specifier: &str,
608    resolved_file: &Path,
609) -> Option<String> {
610    if !specifier.starts_with('#') {
611        return None;
612    }
613    let manifest = nearest_package_manifest(ctx.package_manifests, from_file)?;
614    let imports = manifest.package_json.imports.as_ref()?;
615    let PackageMapTarget::Targets(targets) =
616        package_map_target(imports, specifier, ctx.condition_names)
617    else {
618        return None;
619    };
620    let owner = nearest_package_manifest(ctx.package_manifests, resolved_file)?
621        .name
622        .as_deref()?;
623    targets
624        .iter()
625        .filter(|target| !target.starts_with('#'))
626        .filter_map(|target| package_import_external_target(target))
627        .find(|package_name| package_name == owner)
628}
629
630/// Return the workspace package that a direct bare import reached through its
631/// install link.
632///
633/// npm, yarn and pnpm link a workspace package into `node_modules`, so an
634/// import such as `@acme/lib/x` resolves to the source file of the package.
635/// Node.js uses the first `node_modules/@acme/lib` in the ancestor directories
636/// of the importing file. The package receives the credit only when that link
637/// points at the workspace package root and the resolved file is inside it. An
638/// import that only a tsconfig `paths` alias or a bundler alias resolves has no
639/// such link, so it keeps its plain internal edge.
640pub(super) fn installed_workspace_package_target(
641    ctx: &ResolveContext<'_>,
642    from_file: &Path,
643    specifier: &str,
644    resolved_file: &Path,
645) -> Option<String> {
646    let name = super::package_credit::package_usage_name_for_external_bare_specifier(specifier)?;
647    let workspace_root = *ctx.workspace_roots.get(name.as_str())?;
648    let resolved = ctx
649        .canonicalize_cache
650        .get(resolved_file)
651        .unwrap_or_else(|| resolved_file.to_path_buf());
652    if !resolved.starts_with(workspace_root) {
653        return None;
654    }
655    let installed = from_file.ancestors().skip(1).find_map(|dir| {
656        ctx.canonicalize_cache
657            .get(&dir.join("node_modules").join(&name))
658    })?;
659    (installed == workspace_root).then_some(name)
660}
661
662pub(super) fn normalize_path_lexically(path: &Path) -> PathBuf {
663    let mut normalized = PathBuf::new();
664    for component in path.components() {
665        match component {
666            std::path::Component::CurDir => {}
667            std::path::Component::ParentDir => {
668                if !normalized.pop() {
669                    normalized.push(component.as_os_str());
670                }
671            }
672            std::path::Component::Prefix(_)
673            | std::path::Component::RootDir
674            | std::path::Component::Normal(_) => normalized.push(component.as_os_str()),
675        }
676    }
677    normalized
678}
679
680#[derive(Debug, Clone, PartialEq, Eq)]
681enum PackageMapTarget {
682    NoMatch,
683    Blocked,
684    Targets(Vec<String>),
685}
686
687enum PackageImportTarget {
688    Internal(FileId),
689    ExternalPackage(String),
690}
691
692fn package_map_match_value(
693    value: &Value,
694    condition_names: &[String],
695    capture: Option<&str>,
696) -> PackageMapTarget {
697    resolve_package_map_value(value, condition_names, capture)
698        .filter(|targets| !targets.is_empty())
699        .map_or(PackageMapTarget::Blocked, PackageMapTarget::Targets)
700}
701
702fn package_map_target(
703    map: &Value,
704    specifier_key: &str,
705    condition_names: &[String],
706) -> PackageMapTarget {
707    let Some(obj) = map.as_object() else {
708        if specifier_key == "." {
709            return package_map_match_value(map, condition_names, None);
710        }
711        return PackageMapTarget::NoMatch;
712    };
713
714    let has_subpath_keys = obj
715        .keys()
716        .any(|key| key == "." || key.starts_with("./") || key.starts_with('#'));
717    if !has_subpath_keys {
718        if specifier_key == "." {
719            return package_map_match_value(map, condition_names, None);
720        }
721        return PackageMapTarget::NoMatch;
722    }
723
724    if let Some(value) = obj.get(specifier_key) {
725        return package_map_match_value(value, condition_names, None);
726    }
727
728    let mut patterns: Vec<(&str, &Value, String)> = obj
729        .iter()
730        .filter_map(|(pattern, value)| {
731            package_map_pattern_capture(pattern, specifier_key)
732                .map(|capture| (pattern.as_str(), value, capture))
733        })
734        .collect();
735    patterns.sort_by(|(left, _, _), (right, _, _)| {
736        package_map_pattern_specificity(right).cmp(&package_map_pattern_specificity(left))
737    });
738
739    patterns
740        .first()
741        .map_or(PackageMapTarget::NoMatch, |(_, value, capture)| {
742            package_map_match_value(value, condition_names, Some(capture))
743        })
744}
745
746fn resolve_package_map_value(
747    value: &Value,
748    condition_names: &[String],
749    capture: Option<&str>,
750) -> Option<Vec<String>> {
751    match value {
752        Value::String(target) => Some(vec![match capture {
753            Some(capture) => target.replace('*', capture),
754            None => target.clone(),
755        }]),
756        Value::Object(map) => {
757            for (condition, value) in map {
758                if (condition == "default"
759                    || condition_names
760                        .iter()
761                        .any(|active_condition| active_condition == condition))
762                    && let Some(targets) =
763                        resolve_package_map_value(value, condition_names, capture)
764                {
765                    return Some(targets);
766                }
767            }
768            None
769        }
770        Value::Array(values) => {
771            let targets: Vec<String> = values
772                .iter()
773                .filter_map(|value| resolve_package_map_value(value, condition_names, capture))
774                .flatten()
775                .collect();
776            (!targets.is_empty()).then_some(targets)
777        }
778        Value::Bool(_) | Value::Null | Value::Number(_) => None,
779    }
780}
781
782fn package_map_pattern_capture(pattern: &str, specifier: &str) -> Option<String> {
783    let star = pattern.find('*')?;
784    if pattern[star + 1..].contains('*') {
785        return None;
786    }
787    let (prefix, suffix_with_star) = pattern.split_at(star);
788    let suffix = &suffix_with_star[1..];
789    let captured = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
790    Some(captured.to_string())
791}
792
793fn package_map_pattern_specificity(pattern: &str) -> (usize, usize) {
794    let star = pattern.find('*').unwrap_or(pattern.len());
795    (star, pattern.len())
796}
797
798fn package_import_source_subpath(
799    manifest: &PackageManifestInfo,
800    specifier: &str,
801) -> Option<PathBuf> {
802    let stripped = specifier.strip_prefix('#')?;
803    let without_package_name = manifest
804        .name
805        .as_deref()
806        .and_then(|name| stripped.strip_prefix(name))
807        .and_then(|rest| rest.strip_prefix('/'))
808        .unwrap_or(stripped);
809    if without_package_name.is_empty() {
810        None
811    } else {
812        Some(PathBuf::from(without_package_name))
813    }
814}
815
816pub(super) fn nearest_package_manifest<'a>(
817    manifests: &'a [PackageManifestInfo],
818    from_file: &Path,
819) -> Option<&'a PackageManifestInfo> {
820    manifests
821        .iter()
822        .filter(|manifest| {
823            from_file.starts_with(&manifest.root) || from_file.starts_with(&manifest.canonical_root)
824        })
825        .max_by_key(|manifest| manifest.root.components().count())
826}
827
828fn find_package_manifest<'a>(
829    manifests: &'a [PackageManifestInfo],
830    package_name: &str,
831) -> Option<&'a PackageManifestInfo> {
832    manifests
833        .iter()
834        .find(|manifest| manifest.name.as_deref() == Some(package_name))
835}
836
837fn resolve_package_map_target(
838    ctx: &ResolveContext<'_>,
839    manifest: &PackageManifestInfo,
840    target: &str,
841    source_subpath: Option<&Path>,
842) -> Option<FileId> {
843    let target_path = package_map_target_path(manifest, target)?;
844
845    lookup_internal_file_id(ctx, &target_path)
846        .or_else(|| try_source_fallback(&target_path, ctx.raw_path_to_id))
847        .or_else(|| try_source_fallback(&target_path, ctx.path_to_id))
848        .or_else(|| {
849            try_missing_only_source_fallback(&manifest.root, &target_path, ctx.raw_path_to_id)
850        })
851        .or_else(|| try_missing_only_source_fallback(&manifest.root, &target_path, ctx.path_to_id))
852        .or_else(|| try_tsconfig_output_source(ctx, &target_path))
853        .or_else(|| source_subpath.and_then(|subpath| try_source_subpath(ctx, manifest, subpath)))
854}
855
856/// Join a package map target (`./src/index.ts`) to the package root. A target
857/// that leaves the package root is not a package path.
858fn package_map_target_path(manifest: &PackageManifestInfo, target: &str) -> Option<PathBuf> {
859    let target = target.strip_prefix("./")?;
860    if target.starts_with("../") || target.starts_with('/') {
861        return None;
862    }
863    Some(manifest.root.join(target))
864}
865
866fn resolve_package_map_targets(
867    ctx: &ResolveContext<'_>,
868    manifest: &PackageManifestInfo,
869    targets: &[String],
870    source_subpath: Option<&Path>,
871) -> Option<FileId> {
872    targets
873        .iter()
874        .find_map(|target| resolve_package_map_target(ctx, manifest, target, source_subpath))
875}
876
877fn resolve_package_import_targets(
878    ctx: &ResolveContext<'_>,
879    manifest: &PackageManifestInfo,
880    targets: &[String],
881    source_subpath: Option<&Path>,
882) -> Option<PackageImportTarget> {
883    targets.iter().find_map(|target| {
884        resolve_package_map_target(ctx, manifest, target, source_subpath)
885            .map(PackageImportTarget::Internal)
886            .or_else(|| {
887                package_import_external_target(target).map(PackageImportTarget::ExternalPackage)
888            })
889    })
890}
891
892fn package_import_external_target(target: &str) -> Option<String> {
893    if is_bare_specifier(target) && is_valid_package_name(target) {
894        Some(extract_package_name(target))
895    } else {
896        None
897    }
898}
899
900fn try_source_subpath(
901    ctx: &ResolveContext<'_>,
902    manifest: &PackageManifestInfo,
903    subpath: &Path,
904) -> Option<FileId> {
905    if subpath.as_os_str().is_empty()
906        && let Some(source) = manifest.package_json.source.as_deref()
907        && let Some(source_path) = safe_relative_package_source_path(source)
908        && let Some(file_id) = lookup_internal_file_id(ctx, &manifest.root.join(source_path))
909    {
910        return Some(file_id);
911    }
912
913    for ext in SOURCE_EXTS {
914        let direct = if subpath.as_os_str().is_empty() {
915            manifest.root.join("src").join(format!("index.{ext}"))
916        } else {
917            manifest.root.join("src").join(subpath).with_extension(ext)
918        };
919        if let Some(file_id) = lookup_internal_file_id(ctx, &direct) {
920            return Some(file_id);
921        }
922
923        if !subpath.as_os_str().is_empty() {
924            let index = manifest
925                .root
926                .join("src")
927                .join(subpath)
928                .join(format!("index.{ext}"));
929            if let Some(file_id) = lookup_internal_file_id(ctx, &index) {
930                return Some(file_id);
931            }
932        }
933
934        if subpath.as_os_str().is_empty() {
935            let root_index = manifest.root.join(format!("index.{ext}"));
936            if let Some(file_id) = lookup_internal_file_id(ctx, &root_index) {
937                return Some(file_id);
938            }
939        }
940    }
941
942    None
943}
944
945fn safe_relative_package_source_path(source: &str) -> Option<&Path> {
946    let source = source.strip_prefix("./").unwrap_or(source);
947    let path = Path::new(source);
948    if path.as_os_str().is_empty()
949        || path.components().any(|component| {
950            matches!(
951                component,
952                std::path::Component::ParentDir
953                    | std::path::Component::RootDir
954                    | std::path::Component::Prefix(_)
955            )
956        })
957    {
958        None
959    } else {
960        Some(path)
961    }
962}
963
964pub(super) fn lookup_internal_file_id(
965    ctx: &ResolveContext<'_>,
966    candidate: &Path,
967) -> Option<FileId> {
968    if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
969        return Some(file_id);
970    }
971    if let Some(&file_id) = ctx.path_to_id.get(candidate) {
972        return Some(file_id);
973    }
974    #[cfg(not(miri))]
975    if let Some(canonical) = ctx.canonicalize_cache.get(candidate) {
976        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
977            return Some(file_id);
978        }
979        if let Some(fallback) = ctx.canonical_fallback
980            && let Some(file_id) = fallback.get(&canonical)
981        {
982            return Some(file_id);
983        }
984    }
985    None
986}
987
988/// Extract npm package name from a resolved path inside `node_modules`.
989///
990/// Given a path like `/project/node_modules/react/index.js`, returns `Some("react")`.
991/// Given a path like `/project/node_modules/@scope/pkg/dist/index.js`, returns `Some("@scope/pkg")`.
992/// Returns `None` if the path doesn't contain a `node_modules` segment.
993pub fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
994    let components: Vec<&str> = path
995        .components()
996        .filter_map(|c| match c {
997            std::path::Component::Normal(s) => s.to_str(),
998            _ => None,
999        })
1000        .collect();
1001
1002    let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
1003
1004    let after = &components[nm_idx + 1..];
1005    if after.is_empty() {
1006        return None;
1007    }
1008
1009    if after[0].starts_with('@') {
1010        if after.len() >= 2 {
1011            Some(format!("{}/{}", after[0], after[1]))
1012        } else {
1013            Some(after[0].to_string())
1014        }
1015    } else {
1016        Some(after[0].to_string())
1017    }
1018}
1019
1020/// Try to map a pnpm virtual store path back to a workspace source file.
1021///
1022/// When pnpm uses injected dependencies or certain linking strategies, canonical
1023/// paths go through `.pnpm`:
1024///   `/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/index.js`
1025///
1026/// This function detects such paths, extracts the package name, checks if it
1027/// matches a workspace package, and tries to find the source file in that workspace.
1028pub(super) fn try_pnpm_workspace_fallback(
1029    path: &Path,
1030    path_to_id: &FxHashMap<&Path, FileId>,
1031    workspace_roots: &FxHashMap<&str, &Path>,
1032) -> Option<FileId> {
1033    let components: Vec<&str> = path
1034        .components()
1035        .filter_map(|c| match c {
1036            std::path::Component::Normal(s) => s.to_str(),
1037            _ => None,
1038        })
1039        .collect();
1040
1041    let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
1042
1043    let after_pnpm = &components[pnpm_idx + 1..];
1044
1045    let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
1046    let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
1047
1048    if after_inner_nm.is_empty() {
1049        return None;
1050    }
1051
1052    let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
1053        if after_inner_nm.len() >= 2 {
1054            (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
1055        } else {
1056            return None;
1057        }
1058    } else {
1059        (after_inner_nm[0].to_string(), 1)
1060    };
1061
1062    let ws_root = workspace_roots.get(pkg_name.as_str())?;
1063
1064    let relative_parts = &after_inner_nm[pkg_name_components..];
1065    if relative_parts.is_empty() {
1066        return None;
1067    }
1068
1069    let relative_path: PathBuf = relative_parts.iter().collect();
1070
1071    let direct = ws_root.join(&relative_path);
1072    if let Some(&file_id) = path_to_id.get(direct.as_path()) {
1073        return Some(file_id);
1074    }
1075
1076    try_source_fallback(&direct, path_to_id)
1077}
1078
1079/// Try to resolve a bare specifier as a workspace package reference.
1080///
1081/// When the specifier's package name matches a workspace package, resolve the
1082/// subpath against that package's root directory directly instead of going
1083/// through `node_modules`. Covers two cases:
1084///
1085/// 1. **Self-referencing package imports**: Node.js v12+ lets a package import
1086///    itself via its own name (`import { X } from '@org/pkg/subentry'` from
1087///    inside `@org/pkg`). Angular libraries built with `ng-packagr` rely on
1088///    this to declare secondary entry points.
1089/// 2. **Cross-workspace imports without `node_modules` symlinks**: monorepos
1090///    that have not been installed yet, or bundlers that bypass `node_modules`
1091///    entirely, still need to resolve `@org/other-pkg/sub` to the sibling
1092///    workspace's source file.
1093///
1094/// Strategy: prefer a matching package `exports` target when the manifest has
1095/// one, then try the package source layout directly when no `exports` map exists,
1096/// and finally resolve the stripped subpath as a relative path from inside the
1097/// package root. The manifest branches cover source-only workspaces whose
1098/// package metadata points at missing `dist` output.
1099///
1100/// See issues #106, #641, and #725.
1101pub(super) fn try_workspace_package_fallback(
1102    ctx: &ResolveContext<'_>,
1103    specifier: &str,
1104    style_context: bool,
1105) -> Option<ResolveResult> {
1106    if !super::path_info::is_bare_specifier(specifier) {
1107        return None;
1108    }
1109    let pkg_name = super::path_info::extract_package_name(specifier);
1110
1111    let subpath = specifier
1112        .strip_prefix(pkg_name.as_str())
1113        .and_then(|s| s.strip_prefix('/'))
1114        .unwrap_or("");
1115    let source_subpath = PathBuf::from(subpath);
1116
1117    match try_manifest_workspace_resolution(ctx, &pkg_name, subpath, &source_subpath) {
1118        ManifestWorkspaceResolution::Resolved(result) => return Some(result),
1119        ManifestWorkspaceResolution::Blocked => return None,
1120        ManifestWorkspaceResolution::Continue => {}
1121    }
1122
1123    let ws_root =
1124        if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
1125            manifest.root.as_path()
1126        } else {
1127            *ctx.workspace_roots.get(pkg_name.as_str())?
1128        };
1129
1130    resolve_workspace_self_reference(ctx, ws_root, subpath, pkg_name, style_context)
1131}
1132
1133/// Outcome of attempting workspace resolution through a matching package
1134/// manifest's `exports` map or source layout.
1135enum ManifestWorkspaceResolution {
1136    /// A target was resolved.
1137    Resolved(ResolveResult),
1138    /// An `exports` map exists but the subpath is unmatched or null-blocked.
1139    Blocked,
1140    /// No manifest matched, or it had no usable resolution; keep trying.
1141    Continue,
1142}
1143
1144/// Try resolving via the package manifest: `exports` map first, then the source
1145/// layout for manifests without an `exports` map.
1146fn try_manifest_workspace_resolution(
1147    ctx: &ResolveContext<'_>,
1148    pkg_name: &str,
1149    subpath: &str,
1150    source_subpath: &Path,
1151) -> ManifestWorkspaceResolution {
1152    let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name) else {
1153        return ManifestWorkspaceResolution::Continue;
1154    };
1155
1156    if let Some(exports) = manifest.package_json.exports.as_ref() {
1157        return match package_map_target(exports, &export_key(subpath), ctx.condition_names) {
1158            PackageMapTarget::Targets(targets) => {
1159                if let Some(file_id) =
1160                    resolve_package_map_targets(ctx, manifest, &targets, Some(source_subpath))
1161                {
1162                    return ManifestWorkspaceResolution::Resolved(
1163                        ResolveResult::InternalPackageModule {
1164                            file_id,
1165                            package_name: pkg_name.to_string(),
1166                        },
1167                    );
1168                }
1169                exports_target_asset(manifest, &targets, pkg_name).map_or(
1170                    ManifestWorkspaceResolution::Continue,
1171                    ManifestWorkspaceResolution::Resolved,
1172                )
1173            }
1174            PackageMapTarget::NoMatch | PackageMapTarget::Blocked => {
1175                ManifestWorkspaceResolution::Blocked
1176            }
1177        };
1178    }
1179
1180    if let Some(file_id) = try_source_subpath(ctx, manifest, source_subpath) {
1181        return ManifestWorkspaceResolution::Resolved(ResolveResult::InternalPackageModule {
1182            file_id,
1183            package_name: pkg_name.to_string(),
1184        });
1185    }
1186
1187    ManifestWorkspaceResolution::Continue
1188}
1189
1190/// The `exports` key that a package subpath matches: `.` for the package
1191/// root, else `./<subpath>`.
1192fn export_key(subpath: &str) -> String {
1193    if subpath.is_empty() {
1194        ".".to_string()
1195    } else {
1196        format!("./{subpath}")
1197    }
1198}
1199
1200/// Return the paths that the `exports` map of a project package names for a
1201/// bare `specifier`, when no named path exists on disk.
1202///
1203/// Generated code (`./src/generated/enums.ts`) does not exist before the
1204/// generator runs, so such an import does not resolve. The unresolved-import
1205/// check uses these paths to find out if the repository ignores the target.
1206/// The result is `None` when the specifier does not name a project package,
1207/// when the package has no `exports` map, when no `exports` key matches the
1208/// subpath, when a target leaves the package root, or when a target exists.
1209pub(super) fn missing_package_export_paths(
1210    ctx: &ResolveContext<'_>,
1211    specifier: &str,
1212) -> Option<Vec<PathBuf>> {
1213    if !is_bare_specifier(specifier) {
1214        return None;
1215    }
1216    let pkg_name = extract_package_name(specifier);
1217    let manifest = find_package_manifest(ctx.package_manifests, &pkg_name)?;
1218    let exports = manifest.package_json.exports.as_ref()?;
1219    let subpath = specifier
1220        .strip_prefix(pkg_name.as_str())
1221        .and_then(|s| s.strip_prefix('/'))
1222        .unwrap_or("");
1223    let PackageMapTarget::Targets(targets) =
1224        package_map_target(exports, &export_key(subpath), ctx.condition_names)
1225    else {
1226        return None;
1227    };
1228    let paths = targets
1229        .iter()
1230        .map(|target| package_map_target_path(manifest, target))
1231        .collect::<Option<Vec<_>>>()?;
1232    (!paths.is_empty() && paths.iter().all(|path| !path.exists())).then_some(paths)
1233}
1234
1235/// Resolve the stripped subpath as a relative import from inside the package
1236/// root, mapping the resolved path back to an internal module via the id maps
1237/// and source fallback.
1238fn resolve_workspace_self_reference(
1239    ctx: &ResolveContext<'_>,
1240    ws_root: &Path,
1241    subpath: &str,
1242    package_name: String,
1243    style_context: bool,
1244) -> Option<ResolveResult> {
1245    let rel_spec = if subpath.is_empty() {
1246        "./".to_string()
1247    } else {
1248        format!("./{subpath}")
1249    };
1250
1251    let root_file = ws_root.join("__fallow_ws_self_resolve__");
1252    let super::specifier::ResolveFileAttempt::Resolved {
1253        resolution: resolved,
1254        ..
1255    } = super::specifier::resolve_file_with_tsconfig_fallback(
1256        ctx,
1257        &root_file,
1258        &rel_spec,
1259        style_context,
1260    )
1261    else {
1262        return None;
1263    };
1264    let resolved_path = resolved.path();
1265
1266    if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
1267        return Some(ResolveResult::InternalPackageModule {
1268            file_id,
1269            package_name,
1270        });
1271    }
1272    if let Some(canonical) = ctx.canonicalize_cache.get(resolved_path) {
1273        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
1274            return Some(ResolveResult::InternalPackageModule {
1275                file_id,
1276                package_name,
1277            });
1278        }
1279        if let Some(fallback) = ctx.canonical_fallback
1280            && let Some(file_id) = fallback.get(&canonical)
1281        {
1282            return Some(ResolveResult::InternalPackageModule {
1283                file_id,
1284                package_name,
1285            });
1286        }
1287        if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
1288            return Some(ResolveResult::InternalPackageModule {
1289                file_id,
1290                package_name,
1291            });
1292        }
1293    }
1294    workspace_package_asset(resolved_path, &package_name)
1295}
1296
1297/// Convert a `DynamicImportPattern` to a glob string for file matching.
1298pub(super) fn make_glob_from_pattern(
1299    pattern: &fallow_types::extract::DynamicImportPattern,
1300) -> String {
1301    if has_wildcard_or_alternates(&pattern.prefix)
1302        || fallow_config::extglob::has_extglob_group(&pattern.prefix)
1303    {
1304        return pattern.prefix.clone();
1305    }
1306    pattern.suffix.as_ref().map_or_else(
1307        || format!("{}*", pattern.prefix),
1308        |suffix| format!("{}*{}", pattern.prefix, suffix),
1309    )
1310}
1311
1312/// Whether `prefix` has a `*` or a `{` outside a one-character class. The
1313/// extractor escapes a literal metacharacter as a one-character class, such
1314/// as `[*]`, so an escaped character is not glob syntax.
1315fn has_wildcard_or_alternates(prefix: &str) -> bool {
1316    let bytes = prefix.as_bytes();
1317    let mut index = 0;
1318    while index < bytes.len() {
1319        match bytes[index] {
1320            b'[' if bytes.get(index + 2) == Some(&b']') => index += 3,
1321            b'*' | b'{' => return true,
1322            _ => index += 1,
1323        }
1324    }
1325    false
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330    use super::*;
1331    use crate::resolve::types::{CanonicalizeCache, TsconfigCache};
1332    use fallow_types::extract::ModuleLoadMechanism;
1333    use rustc_hash::FxHashSet;
1334
1335    pub(super) fn with_package_map_ctx(
1336        root: PathBuf,
1337        name: Option<&str>,
1338        package_json: fallow_config::PackageJson,
1339        raw_files: &[(PathBuf, FileId)],
1340        f: impl FnOnce(&ResolveContext<'_>, &PackageManifestInfo, &Path),
1341    ) {
1342        let manifest = PackageManifestInfo {
1343            root: root.clone(),
1344            canonical_root: root,
1345            name: name.map(str::to_string),
1346            package_json,
1347            deno_import_map: Vec::new(),
1348        };
1349        let manifests = [manifest];
1350        let mut raw_path_to_id = FxHashMap::default();
1351        for (path, file_id) in raw_files {
1352            raw_path_to_id.insert(path.as_path(), *file_id);
1353        }
1354        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1355        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1356        let condition_names = conditions();
1357        let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
1358        let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
1359        let tsconfig_cache = TsconfigCache::default();
1360        let canonicalize_cache = CanonicalizeCache::default();
1361        let script_resolver = crate::resolve::specifier::create_script_resolver(&resolver);
1362        let ctx = ResolveContext {
1363            resolver: &resolver,
1364            script_resolver: &script_resolver,
1365            style_resolver: &resolver,
1366            extensions: &[],
1367            path_to_id: &path_to_id,
1368            raw_path_to_id: &raw_path_to_id,
1369            workspace_roots: &workspace_roots,
1370            package_manifests: &manifests,
1371            has_deno_import_maps: false,
1372            condition_names: &condition_names,
1373            path_aliases: &[],
1374            scss_include_paths: &[],
1375            static_dir_mappings: &[],
1376            framework_static_dir_mappings: &[],
1377            root: &manifests[0].root,
1378            canonical_fallback: None,
1379            tsconfig_warned: &tsconfig_warned,
1380            tsconfig_cache: &tsconfig_cache,
1381            canonicalize_cache: &canonicalize_cache,
1382        };
1383
1384        f(&ctx, &manifests[0], &manifests[0].root);
1385    }
1386
1387    #[test]
1388    fn alias_match_remainder_exact_key() {
1389        assert_eq!(alias_match_remainder("vscode", "vscode"), Some(""));
1390        assert_eq!(alias_match_remainder("@scope/sdk", "@scope/sdk"), Some(""));
1391    }
1392
1393    #[test]
1394    fn alias_match_remainder_slash_continuation() {
1395        assert_eq!(
1396            alias_match_remainder("@scope/sdk/sub", "@scope/sdk"),
1397            Some("/sub")
1398        );
1399        assert_eq!(alias_match_remainder("@/foo", "@/"), Some("foo"));
1400        assert_eq!(
1401            alias_match_remainder("~/components/x", "~/"),
1402            Some("components/x")
1403        );
1404        assert_eq!(alias_match_remainder("$lib/util", "$lib/"), Some("util"));
1405    }
1406
1407    #[test]
1408    fn alias_match_remainder_rejects_prefix_collision() {
1409        assert_eq!(
1410            alias_match_remainder("@scope/sdk-extra", "@scope/sdk"),
1411            None
1412        );
1413        assert_eq!(
1414            alias_match_remainder("vscode-languageserver", "vscode"),
1415            None
1416        );
1417        assert_eq!(alias_match_remainder("#shared-utils", "#shared"), None);
1418    }
1419
1420    #[test]
1421    fn alias_match_remainder_non_match() {
1422        assert_eq!(alias_match_remainder("react", "vscode"), None);
1423    }
1424
1425    #[test]
1426    fn test_extract_package_name_from_node_modules_path_regular() {
1427        let path = PathBuf::from("/project/node_modules/react/index.js");
1428        assert_eq!(
1429            extract_package_name_from_node_modules_path(&path),
1430            Some("react".to_string())
1431        );
1432    }
1433
1434    #[test]
1435    fn test_extract_package_name_from_node_modules_path_scoped() {
1436        let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
1437        assert_eq!(
1438            extract_package_name_from_node_modules_path(&path),
1439            Some("@babel/core".to_string())
1440        );
1441    }
1442
1443    #[test]
1444    fn test_extract_package_name_from_node_modules_path_nested() {
1445        let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
1446        assert_eq!(
1447            extract_package_name_from_node_modules_path(&path),
1448            Some("pkg-b".to_string())
1449        );
1450    }
1451
1452    #[test]
1453    fn test_extract_package_name_from_node_modules_path_deep_subpath() {
1454        let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
1455        assert_eq!(
1456            extract_package_name_from_node_modules_path(&path),
1457            Some("react-dom".to_string())
1458        );
1459    }
1460
1461    #[test]
1462    fn test_extract_package_name_from_node_modules_path_no_node_modules() {
1463        let path = PathBuf::from("/project/src/components/Button.tsx");
1464        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1465    }
1466
1467    #[test]
1468    fn test_extract_package_name_from_node_modules_path_just_node_modules() {
1469        let path = PathBuf::from("/project/node_modules");
1470        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1471    }
1472
1473    #[test]
1474    fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
1475        let path = PathBuf::from("/project/node_modules/@scope");
1476        assert_eq!(
1477            extract_package_name_from_node_modules_path(&path),
1478            Some("@scope".to_string())
1479        );
1480    }
1481
1482    #[test]
1483    fn test_resolve_specifier_node_modules_returns_npm_package() {
1484        let path =
1485            PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
1486        assert_eq!(
1487            extract_package_name_from_node_modules_path(&path),
1488            Some("styled-components".to_string())
1489        );
1490
1491        let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
1492        assert_eq!(
1493            extract_package_name_from_node_modules_path(&path),
1494            Some("next".to_string())
1495        );
1496    }
1497
1498    #[test]
1499    fn test_try_source_fallback_dist_to_src() {
1500        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1501        let mut path_to_id = FxHashMap::default();
1502        path_to_id.insert(src_path.as_path(), FileId(0));
1503
1504        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1505        assert_eq!(
1506            try_source_fallback(&dist_path, &path_to_id),
1507            Some(FileId(0)),
1508            "dist/utils.js should fall back to src/utils.ts"
1509        );
1510    }
1511
1512    #[test]
1513    fn test_try_source_fallback_build_to_src() {
1514        let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1515        let mut path_to_id = FxHashMap::default();
1516        path_to_id.insert(src_path.as_path(), FileId(1));
1517
1518        let build_path = PathBuf::from("/project/packages/core/build/index.js");
1519        assert_eq!(
1520            try_source_fallback(&build_path, &path_to_id),
1521            Some(FileId(1)),
1522            "build/index.js should fall back to src/index.tsx"
1523        );
1524    }
1525
1526    #[test]
1527    fn test_try_source_fallback_no_match() {
1528        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1529
1530        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1531        assert_eq!(
1532            try_source_fallback(&dist_path, &path_to_id),
1533            None,
1534            "should return None when no source file exists"
1535        );
1536    }
1537
1538    #[test]
1539    fn test_try_source_fallback_non_output_dir() {
1540        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1541        let mut path_to_id = FxHashMap::default();
1542        path_to_id.insert(src_path.as_path(), FileId(0));
1543
1544        let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1545        assert_eq!(
1546            try_source_fallback(&normal_path, &path_to_id),
1547            None,
1548            "non-output directory path should not trigger fallback"
1549        );
1550    }
1551
1552    #[test]
1553    fn test_try_source_fallback_nested_path() {
1554        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1555        let mut path_to_id = FxHashMap::default();
1556        path_to_id.insert(src_path.as_path(), FileId(2));
1557
1558        let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1559        assert_eq!(
1560            try_source_fallback(&dist_path, &path_to_id),
1561            Some(FileId(2)),
1562            "nested dist path should fall back to nested src path"
1563        );
1564    }
1565
1566    #[test]
1567    fn test_try_source_fallback_nested_dist_esm() {
1568        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1569        let mut path_to_id = FxHashMap::default();
1570        path_to_id.insert(src_path.as_path(), FileId(0));
1571
1572        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1573        assert_eq!(
1574            try_source_fallback(&dist_path, &path_to_id),
1575            Some(FileId(0)),
1576            "dist/esm/utils.mjs should fall back to src/utils.ts"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_try_source_fallback_nested_build_cjs() {
1582        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1583        let mut path_to_id = FxHashMap::default();
1584        path_to_id.insert(src_path.as_path(), FileId(1));
1585
1586        let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1587        assert_eq!(
1588            try_source_fallback(&build_path, &path_to_id),
1589            Some(FileId(1)),
1590            "build/cjs/index.cjs should fall back to src/index.ts"
1591        );
1592    }
1593
1594    #[test]
1595    fn test_try_source_fallback_nested_dist_esm_deep_path() {
1596        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1597        let mut path_to_id = FxHashMap::default();
1598        path_to_id.insert(src_path.as_path(), FileId(2));
1599
1600        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1601        assert_eq!(
1602            try_source_fallback(&dist_path, &path_to_id),
1603            Some(FileId(2)),
1604            "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1605        );
1606    }
1607
1608    #[test]
1609    fn test_try_source_fallback_triple_nested_output_dirs() {
1610        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1611        let mut path_to_id = FxHashMap::default();
1612        path_to_id.insert(src_path.as_path(), FileId(0));
1613
1614        let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1615        assert_eq!(
1616            try_source_fallback(&dist_path, &path_to_id),
1617            Some(FileId(0)),
1618            "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1619        );
1620    }
1621
1622    #[test]
1623    fn test_try_source_fallback_parent_dir_named_build() {
1624        let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1625        let mut path_to_id = FxHashMap::default();
1626        path_to_id.insert(src_path.as_path(), FileId(0));
1627
1628        let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1629        assert_eq!(
1630            try_source_fallback(&dist_path, &path_to_id),
1631            Some(FileId(0)),
1632            "should resolve dist/ within project, not match parent 'build' dir"
1633        );
1634    }
1635
1636    #[test]
1637    fn package_map_exact_entry_beats_pattern_entry() {
1638        let map = serde_json::json!({
1639            "#nitro/runtime/task": "./dist/special/task.mjs",
1640            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1641        });
1642        assert_eq!(
1643            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1644            PackageMapTarget::Targets(vec!["./dist/special/task.mjs".to_string()])
1645        );
1646    }
1647
1648    #[test]
1649    fn package_map_wildcard_substitutes_capture() {
1650        let map = serde_json::json!({
1651            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1652        });
1653        assert_eq!(
1654            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1655            PackageMapTarget::Targets(vec!["./dist/runtime/internal/task.mjs".to_string()])
1656        );
1657    }
1658
1659    #[test]
1660    fn package_map_exact_entry_with_no_target_blocks_pattern_entry() {
1661        let map = serde_json::json!({
1662            "#nitro/runtime/task": null,
1663            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1664        });
1665        assert_eq!(
1666            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1667            PackageMapTarget::Blocked
1668        );
1669    }
1670
1671    #[test]
1672    fn package_map_best_pattern_with_no_target_blocks_broader_pattern() {
1673        let map = serde_json::json!({
1674            "#nitro/runtime/internal/*": null,
1675            "#nitro/runtime/*": "./dist/runtime/*.mjs"
1676        });
1677        assert_eq!(
1678            package_map_target(&map, "#nitro/runtime/internal/task", &conditions()),
1679            PackageMapTarget::Blocked
1680        );
1681    }
1682
1683    #[test]
1684    fn package_map_unmatched_subpath_is_not_a_target() {
1685        let map = serde_json::json!({
1686            "./query": "./dist/query/index.js"
1687        });
1688        assert_eq!(
1689            package_map_target(&map, "./private", &conditions()),
1690            PackageMapTarget::NoMatch
1691        );
1692    }
1693
1694    #[test]
1695    fn package_map_nested_conditions_follow_manifest_order() {
1696        let map = serde_json::json!({
1697            "./query/react": {
1698                "types": "./dist/query/react/index.d.ts",
1699                "import": {
1700                    "development": "./src/query/react/index.ts",
1701                    "default": "./dist/query/react/index.js"
1702                },
1703                "default": "./dist/query/react/index.cjs"
1704            }
1705        });
1706        assert_eq!(
1707            package_map_target(&map, "./query/react", &conditions()),
1708            PackageMapTarget::Targets(vec!["./dist/query/react/index.d.ts".to_string()])
1709        );
1710    }
1711
1712    #[test]
1713    fn package_map_import_before_types_selects_runtime_branch() {
1714        let map = serde_json::json!({
1715            ".": {
1716                "import": "./dist/index.js",
1717                "types": "./dist/index.d.ts"
1718            }
1719        });
1720        assert_eq!(
1721            package_map_target(&map, ".", &conditions()),
1722            PackageMapTarget::Targets(vec!["./dist/index.js".to_string()])
1723        );
1724    }
1725
1726    #[test]
1727    fn package_map_condition_order_follows_manifest_order() {
1728        let map = serde_json::json!({
1729            ".": {
1730                "node": "./dist/node.js",
1731                "import": "./dist/index.js"
1732            }
1733        });
1734        assert_eq!(
1735            package_map_target(&map, ".", &conditions()),
1736            PackageMapTarget::Targets(vec!["./dist/node.js".to_string()])
1737        );
1738    }
1739
1740    #[test]
1741    fn package_map_arrays_preserve_fallback_order() {
1742        let map = serde_json::json!({
1743            "#array": ["./dist/missing.js", "./src/array.ts"],
1744            "#null": null,
1745            "#false": false
1746        });
1747        assert_eq!(
1748            package_map_target(&map, "#array", &conditions()),
1749            PackageMapTarget::Targets(vec![
1750                "./dist/missing.js".to_string(),
1751                "./src/array.ts".to_string()
1752            ])
1753        );
1754        assert_eq!(
1755            package_map_target(&map, "#null", &conditions()),
1756            PackageMapTarget::Blocked
1757        );
1758        assert_eq!(
1759            package_map_target(&map, "#false", &conditions()),
1760            PackageMapTarget::Blocked
1761        );
1762    }
1763
1764    #[test]
1765    fn package_map_non_relative_target_does_not_trigger_source_fallback() {
1766        with_package_map_ctx(
1767            PathBuf::from("/project"),
1768            Some("pkg"),
1769            fallow_config::PackageJson::default(),
1770            &[],
1771            |ctx, manifest, _| {
1772                assert!(resolve_package_map_target(ctx, manifest, "lodash", None).is_none());
1773                assert!(
1774                    resolve_package_map_target(ctx, manifest, "../dist/index.js", None).is_none()
1775                );
1776            },
1777        );
1778    }
1779
1780    #[test]
1781    fn package_map_lib_target_maps_to_source_when_absent() {
1782        let root = PathBuf::from("/project/packages/uses-lib");
1783        let feature = root.join("src/features/feature.ts");
1784        let index = root.join("src/index.ts");
1785
1786        with_package_map_ctx(
1787            root,
1788            Some("@repro/uses-lib"),
1789            fallow_config::PackageJson::default(),
1790            &[(feature, FileId(3)), (index, FileId(4))],
1791            |ctx, manifest, _| {
1792                assert_eq!(
1793                    resolve_package_map_target(ctx, manifest, "./lib/features/feature.mjs", None),
1794                    Some(FileId(3))
1795                );
1796                assert_eq!(
1797                    resolve_package_map_target(ctx, manifest, "./lib/index.d.ts", None),
1798                    Some(FileId(4))
1799                );
1800            },
1801        );
1802    }
1803
1804    #[test]
1805    fn package_map_tracked_lib_target_wins_over_source() {
1806        let root = PathBuf::from("/project");
1807        let lib_path = root.join("lib/index.js");
1808        let src_path = root.join("src/index.ts");
1809
1810        with_package_map_ctx(
1811            root,
1812            Some("pkg"),
1813            fallow_config::PackageJson::default(),
1814            &[(lib_path, FileId(1)), (src_path, FileId(2))],
1815            |ctx, manifest, _| {
1816                assert_eq!(
1817                    resolve_package_map_target(ctx, manifest, "./lib/index.js", None),
1818                    Some(FileId(1))
1819                );
1820            },
1821        );
1822    }
1823
1824    #[test]
1825    fn generic_source_fallback_does_not_map_lib() {
1826        let src_path = PathBuf::from("/project/src/x.ts");
1827        let mut path_to_id = FxHashMap::default();
1828        path_to_id.insert(src_path.as_path(), FileId(0));
1829
1830        assert_eq!(
1831            try_source_fallback(Path::new("/project/lib/x.js"), &path_to_id),
1832            None
1833        );
1834    }
1835
1836    #[test]
1837    fn package_map_dist_lib_target_prefers_output_dirs_mapping() {
1838        let root = PathBuf::from("/project");
1839        let nested = root.join("src/lib/x.ts");
1840        let flat = root.join("src/x.ts");
1841
1842        with_package_map_ctx(
1843            root,
1844            Some("pkg"),
1845            fallow_config::PackageJson::default(),
1846            &[(nested, FileId(6)), (flat, FileId(7))],
1847            |ctx, manifest, _| {
1848                assert_eq!(
1849                    resolve_package_map_target(ctx, manifest, "./dist/lib/x.js", None),
1850                    Some(FileId(6))
1851                );
1852            },
1853        );
1854    }
1855
1856    #[test]
1857    fn package_imports_fallback_supports_external_package_targets() {
1858        let root = PathBuf::from("/project");
1859        with_package_map_ctx(
1860            root,
1861            Some("pkg"),
1862            fallow_config::PackageJson {
1863                imports: Some(serde_json::json!({
1864                    "#pad": "left-pad",
1865                    "#scoped": "@scope/pkg/subpath"
1866                })),
1867                ..Default::default()
1868            },
1869            &[],
1870            |ctx, _, root| {
1871                let pad = try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#pad");
1872                assert!(matches!(pad, Some(ResolveResult::NpmPackage(pkg)) if pkg == "left-pad"));
1873
1874                let scoped =
1875                    try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#scoped");
1876                assert!(
1877                    matches!(scoped, Some(ResolveResult::NpmPackage(pkg)) if pkg == "@scope/pkg")
1878                );
1879            },
1880        );
1881    }
1882
1883    #[test]
1884    fn package_imports_workspace_target_names_only_known_packages() {
1885        let root = PathBuf::from("/project");
1886        with_package_map_ctx(
1887            root,
1888            Some("pkg"),
1889            fallow_config::PackageJson {
1890                imports: Some(serde_json::json!({
1891                    "#self/*": "pkg/*",
1892                    "#pad": "left-pad",
1893                    "#local/*": "./src/*.ts"
1894                })),
1895                ..Default::default()
1896            },
1897            &[],
1898            |ctx, _, root| {
1899                let from = root.join("src/index.ts");
1900                let feature = root.join("src/feature.ts");
1901                assert_eq!(
1902                    package_imports_workspace_target(ctx, &from, "#self/feature", &feature),
1903                    Some("pkg".to_string())
1904                );
1905                assert_eq!(
1906                    package_imports_workspace_target(
1907                        ctx,
1908                        &from,
1909                        "#self/feature",
1910                        Path::new("/elsewhere/feature.ts")
1911                    ),
1912                    None,
1913                    "a file outside the named package must not credit it"
1914                );
1915                assert_eq!(
1916                    package_imports_workspace_target(
1917                        ctx,
1918                        &from,
1919                        "#pad",
1920                        &root.join("node_modules/left-pad/index.js")
1921                    ),
1922                    None
1923                );
1924                assert_eq!(
1925                    package_imports_workspace_target(ctx, &from, "#local/feature", &feature),
1926                    None
1927                );
1928                assert_eq!(
1929                    package_imports_workspace_target(ctx, &from, "pkg/feature", &feature),
1930                    None
1931                );
1932            },
1933        );
1934    }
1935
1936    #[test]
1937    fn package_imports_fallback_supports_unnamed_packages() {
1938        let root = PathBuf::from("/project");
1939        let src_path = root.join("src/runtime/task.ts");
1940        with_package_map_ctx(
1941            root,
1942            None,
1943            fallow_config::PackageJson {
1944                imports: Some(serde_json::json!({
1945                    "#runtime/*": "./dist/runtime/*.mjs"
1946                })),
1947                ..Default::default()
1948            },
1949            &[(src_path, FileId(7))],
1950            |ctx, _, root| {
1951                let result =
1952                    try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#runtime/task");
1953                assert!(matches!(
1954                    result,
1955                    Some(ResolveResult::InternalModule(FileId(7)))
1956                ));
1957            },
1958        );
1959    }
1960
1961    #[test]
1962    fn package_source_path_accepts_relative_source_entries() {
1963        assert_eq!(
1964            safe_relative_package_source_path("src/index.js"),
1965            Some(Path::new("src/index.js"))
1966        );
1967        assert_eq!(
1968            safe_relative_package_source_path("./custom/entry.ts"),
1969            Some(Path::new("custom/entry.ts"))
1970        );
1971    }
1972
1973    #[test]
1974    fn package_source_path_rejects_unsafe_entries() {
1975        assert_eq!(safe_relative_package_source_path(""), None);
1976        assert_eq!(safe_relative_package_source_path("./"), None);
1977        assert_eq!(safe_relative_package_source_path("../src/index.js"), None);
1978        assert_eq!(safe_relative_package_source_path("src/../index.js"), None);
1979        assert_eq!(safe_relative_package_source_path("/src/index.js"), None);
1980
1981        #[cfg(windows)]
1982        assert_eq!(safe_relative_package_source_path("C:\\src\\index.js"), None);
1983    }
1984
1985    #[test]
1986    fn test_pnpm_store_path_extract_package_name() {
1987        let path =
1988            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1989        assert_eq!(
1990            extract_package_name_from_node_modules_path(&path),
1991            Some("react".to_string())
1992        );
1993    }
1994
1995    #[test]
1996    fn test_pnpm_store_path_scoped_package() {
1997        let path = PathBuf::from(
1998            "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1999        );
2000        assert_eq!(
2001            extract_package_name_from_node_modules_path(&path),
2002            Some("@babel/core".to_string())
2003        );
2004    }
2005
2006    fn conditions() -> Vec<String> {
2007        vec![
2008            "development".to_string(),
2009            "import".to_string(),
2010            "require".to_string(),
2011            "default".to_string(),
2012            "types".to_string(),
2013            "node".to_string(),
2014        ]
2015    }
2016
2017    #[test]
2018    fn test_pnpm_store_path_with_peer_deps() {
2019        let path = PathBuf::from(
2020            "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
2021        );
2022        assert_eq!(
2023            extract_package_name_from_node_modules_path(&path),
2024            Some("webpack".to_string())
2025        );
2026    }
2027
2028    #[test]
2029    fn test_try_pnpm_workspace_fallback_dist_to_src() {
2030        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
2031        let mut path_to_id = FxHashMap::default();
2032        path_to_id.insert(src_path.as_path(), FileId(0));
2033
2034        let mut workspace_roots = FxHashMap::default();
2035        let ws_root = PathBuf::from("/project/packages/ui");
2036        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2037
2038        let pnpm_path = PathBuf::from(
2039            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
2040        );
2041        assert_eq!(
2042            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2043            Some(FileId(0)),
2044            ".pnpm workspace path should fall back to src/utils.ts"
2045        );
2046    }
2047
2048    #[test]
2049    fn test_try_pnpm_workspace_fallback_direct_source() {
2050        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
2051        let mut path_to_id = FxHashMap::default();
2052        path_to_id.insert(src_path.as_path(), FileId(1));
2053
2054        let mut workspace_roots = FxHashMap::default();
2055        let ws_root = PathBuf::from("/project/packages/core");
2056        workspace_roots.insert("@myorg/core", ws_root.as_path());
2057
2058        let pnpm_path = PathBuf::from(
2059            "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
2060        );
2061        assert_eq!(
2062            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2063            Some(FileId(1)),
2064            ".pnpm workspace path with src/ should resolve directly"
2065        );
2066    }
2067
2068    #[test]
2069    fn test_try_pnpm_workspace_fallback_non_workspace_package() {
2070        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2071
2072        let mut workspace_roots = FxHashMap::default();
2073        let ws_root = PathBuf::from("/project/packages/ui");
2074        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2075
2076        let pnpm_path =
2077            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
2078        assert_eq!(
2079            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2080            None,
2081            "non-workspace package in .pnpm should return None"
2082        );
2083    }
2084
2085    #[test]
2086    fn test_try_pnpm_workspace_fallback_unscoped_package() {
2087        let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
2088        let mut path_to_id = FxHashMap::default();
2089        path_to_id.insert(src_path.as_path(), FileId(2));
2090
2091        let mut workspace_roots = FxHashMap::default();
2092        let ws_root = PathBuf::from("/project/packages/utils");
2093        workspace_roots.insert("my-utils", ws_root.as_path());
2094
2095        let pnpm_path = PathBuf::from(
2096            "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
2097        );
2098        assert_eq!(
2099            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2100            Some(FileId(2)),
2101            "unscoped workspace package in .pnpm should resolve"
2102        );
2103    }
2104
2105    #[test]
2106    fn test_try_pnpm_workspace_fallback_nested_path() {
2107        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
2108        let mut path_to_id = FxHashMap::default();
2109        path_to_id.insert(src_path.as_path(), FileId(3));
2110
2111        let mut workspace_roots = FxHashMap::default();
2112        let ws_root = PathBuf::from("/project/packages/ui");
2113        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2114
2115        let pnpm_path = PathBuf::from(
2116            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
2117        );
2118        assert_eq!(
2119            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2120            Some(FileId(3)),
2121            "nested .pnpm workspace path should resolve through source fallback"
2122        );
2123    }
2124
2125    #[test]
2126    fn test_try_pnpm_workspace_fallback_no_pnpm() {
2127        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2128        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2129
2130        let regular_path = PathBuf::from("/project/node_modules/react/index.js");
2131        assert_eq!(
2132            try_pnpm_workspace_fallback(&regular_path, &path_to_id, &workspace_roots),
2133            None,
2134        );
2135    }
2136
2137    #[test]
2138    fn test_try_pnpm_workspace_fallback_with_peer_deps() {
2139        let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
2140        let mut path_to_id = FxHashMap::default();
2141        path_to_id.insert(src_path.as_path(), FileId(4));
2142
2143        let mut workspace_roots = FxHashMap::default();
2144        let ws_root = PathBuf::from("/project/packages/ui");
2145        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2146
2147        let pnpm_path = PathBuf::from(
2148            "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
2149        );
2150        assert_eq!(
2151            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2152            Some(FileId(4)),
2153            ".pnpm path with peer dep suffix should still resolve"
2154        );
2155    }
2156
2157    #[test]
2158    fn make_glob_prefix_only_no_suffix() {
2159        let pattern = fallow_types::extract::DynamicImportPattern {
2160            prefix: "./locales/".to_string(),
2161            suffix: None,
2162            span: oxc_span::Span::default(),
2163            mechanism: ModuleLoadMechanism::EsModule,
2164            excludes: Vec::new(),
2165        };
2166        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*");
2167    }
2168
2169    #[test]
2170    fn make_glob_passthrough_extglob_group() {
2171        let pattern = fallow_types::extract::DynamicImportPattern {
2172            prefix: "./locales/@(en|nl).js".to_string(),
2173            suffix: None,
2174            span: oxc_span::Span::default(),
2175            mechanism: ModuleLoadMechanism::EsModule,
2176            excludes: Vec::new(),
2177        };
2178        assert_eq!(make_glob_from_pattern(&pattern), "./locales/@(en|nl).js");
2179    }
2180
2181    #[test]
2182    fn make_glob_keeps_suffix_after_escaped_metacharacters() {
2183        let pattern = fallow_types::extract::DynamicImportPattern {
2184            prefix: "./[{]group[}]/[*]/".to_string(),
2185            suffix: Some(".js".to_string()),
2186            span: oxc_span::Span::default(),
2187            mechanism: ModuleLoadMechanism::EsModule,
2188            excludes: Vec::new(),
2189        };
2190        assert_eq!(make_glob_from_pattern(&pattern), "./[{]group[}]/[*]/*.js");
2191    }
2192
2193    #[test]
2194    fn make_glob_prefix_with_suffix() {
2195        let pattern = fallow_types::extract::DynamicImportPattern {
2196            prefix: "./locales/".to_string(),
2197            suffix: Some(".json".to_string()),
2198            span: oxc_span::Span::default(),
2199            mechanism: ModuleLoadMechanism::EsModule,
2200            excludes: Vec::new(),
2201        };
2202        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
2203    }
2204
2205    #[test]
2206    fn make_glob_passthrough_star() {
2207        let pattern = fallow_types::extract::DynamicImportPattern {
2208            prefix: "./pages/**/*.tsx".to_string(),
2209            suffix: None,
2210            span: oxc_span::Span::default(),
2211            mechanism: ModuleLoadMechanism::EsModule,
2212            excludes: Vec::new(),
2213        };
2214        assert_eq!(make_glob_from_pattern(&pattern), "./pages/**/*.tsx");
2215    }
2216
2217    #[test]
2218    fn make_glob_passthrough_brace() {
2219        let pattern = fallow_types::extract::DynamicImportPattern {
2220            prefix: "./i18n/{en,de,fr}.json".to_string(),
2221            suffix: None,
2222            span: oxc_span::Span::default(),
2223            mechanism: ModuleLoadMechanism::EsModule,
2224            excludes: Vec::new(),
2225        };
2226        assert_eq!(make_glob_from_pattern(&pattern), "./i18n/{en,de,fr}.json");
2227    }
2228
2229    #[test]
2230    fn make_glob_empty_prefix_no_suffix() {
2231        let pattern = fallow_types::extract::DynamicImportPattern {
2232            prefix: String::new(),
2233            suffix: None,
2234            span: oxc_span::Span::default(),
2235            mechanism: ModuleLoadMechanism::EsModule,
2236            excludes: Vec::new(),
2237        };
2238        assert_eq!(make_glob_from_pattern(&pattern), "*");
2239    }
2240
2241    #[test]
2242    fn make_glob_empty_prefix_with_suffix() {
2243        let pattern = fallow_types::extract::DynamicImportPattern {
2244            prefix: String::new(),
2245            suffix: Some(".ts".to_string()),
2246            span: oxc_span::Span::default(),
2247            mechanism: ModuleLoadMechanism::EsModule,
2248            excludes: Vec::new(),
2249        };
2250        assert_eq!(make_glob_from_pattern(&pattern), "*.ts");
2251    }
2252
2253    #[test]
2254    fn make_glob_template_literal_prefix_only() {
2255        let pattern = fallow_types::extract::DynamicImportPattern {
2256            prefix: "./pages/".to_string(),
2257            suffix: None,
2258            span: oxc_span::Span::default(),
2259            mechanism: ModuleLoadMechanism::EsModule,
2260            excludes: Vec::new(),
2261        };
2262        assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
2263    }
2264
2265    #[test]
2266    fn make_glob_template_literal_deep_prefix() {
2267        let pattern = fallow_types::extract::DynamicImportPattern {
2268            prefix: "./modules/".to_string(),
2269            suffix: None,
2270            span: oxc_span::Span::default(),
2271            mechanism: ModuleLoadMechanism::EsModule,
2272            excludes: Vec::new(),
2273        };
2274        assert_eq!(make_glob_from_pattern(&pattern), "./modules/*");
2275    }
2276
2277    #[test]
2278    fn make_glob_string_concat_with_extension() {
2279        let pattern = fallow_types::extract::DynamicImportPattern {
2280            prefix: "./views/".to_string(),
2281            suffix: Some(".vue".to_string()),
2282            span: oxc_span::Span::default(),
2283            mechanism: ModuleLoadMechanism::EsModule,
2284            excludes: Vec::new(),
2285        };
2286        assert_eq!(make_glob_from_pattern(&pattern), "./views/*.vue");
2287    }
2288
2289    #[test]
2290    fn make_glob_import_meta_glob_recursive() {
2291        let pattern = fallow_types::extract::DynamicImportPattern {
2292            prefix: "./components/**/*.vue".to_string(),
2293            suffix: None,
2294            span: oxc_span::Span::default(),
2295            mechanism: ModuleLoadMechanism::EsModule,
2296            excludes: Vec::new(),
2297        };
2298        assert_eq!(
2299            make_glob_from_pattern(&pattern),
2300            "./components/**/*.vue",
2301            "import.meta.glob patterns with * should pass through as-is"
2302        );
2303    }
2304
2305    #[test]
2306    fn make_glob_import_meta_glob_brace_expansion() {
2307        let pattern = fallow_types::extract::DynamicImportPattern {
2308            prefix: "./plugins/{auth,analytics}.ts".to_string(),
2309            suffix: None,
2310            span: oxc_span::Span::default(),
2311            mechanism: ModuleLoadMechanism::EsModule,
2312            excludes: Vec::new(),
2313        };
2314        assert_eq!(
2315            make_glob_from_pattern(&pattern),
2316            "./plugins/{auth,analytics}.ts",
2317            "import.meta.glob patterns with braces should pass through as-is"
2318        );
2319    }
2320
2321    #[test]
2322    fn make_glob_import_meta_glob_star_with_brace() {
2323        let pattern = fallow_types::extract::DynamicImportPattern {
2324            prefix: "./routes/**/*.{ts,tsx}".to_string(),
2325            suffix: None,
2326            span: oxc_span::Span::default(),
2327            mechanism: ModuleLoadMechanism::EsModule,
2328            excludes: Vec::new(),
2329        };
2330        assert_eq!(
2331            make_glob_from_pattern(&pattern),
2332            "./routes/**/*.{ts,tsx}",
2333            "combined * and brace patterns should pass through"
2334        );
2335    }
2336
2337    #[test]
2338    fn make_glob_import_meta_glob_ignores_suffix_when_star_present() {
2339        let pattern = fallow_types::extract::DynamicImportPattern {
2340            prefix: "./*.ts".to_string(),
2341            suffix: Some(".extra".to_string()),
2342            span: oxc_span::Span::default(),
2343            mechanism: ModuleLoadMechanism::EsModule,
2344            excludes: Vec::new(),
2345        };
2346        assert_eq!(
2347            make_glob_from_pattern(&pattern),
2348            "./*.ts",
2349            "when prefix has glob chars, suffix is ignored (prefix used as-is)"
2350        );
2351    }
2352
2353    #[test]
2354    fn make_glob_single_dot_prefix() {
2355        let pattern = fallow_types::extract::DynamicImportPattern {
2356            prefix: "./".to_string(),
2357            suffix: None,
2358            span: oxc_span::Span::default(),
2359            mechanism: ModuleLoadMechanism::EsModule,
2360            excludes: Vec::new(),
2361        };
2362        assert_eq!(make_glob_from_pattern(&pattern), "./*");
2363    }
2364
2365    #[test]
2366    fn make_glob_prefix_without_trailing_slash() {
2367        let pattern = fallow_types::extract::DynamicImportPattern {
2368            prefix: "./config".to_string(),
2369            suffix: None,
2370            span: oxc_span::Span::default(),
2371            mechanism: ModuleLoadMechanism::EsModule,
2372            excludes: Vec::new(),
2373        };
2374        assert_eq!(make_glob_from_pattern(&pattern), "./config*");
2375    }
2376
2377    #[test]
2378    fn make_glob_prefix_with_dotdot() {
2379        let pattern = fallow_types::extract::DynamicImportPattern {
2380            prefix: "../shared/".to_string(),
2381            suffix: Some(".ts".to_string()),
2382            span: oxc_span::Span::default(),
2383            mechanism: ModuleLoadMechanism::EsModule,
2384            excludes: Vec::new(),
2385        };
2386        assert_eq!(make_glob_from_pattern(&pattern), "../shared/*.ts");
2387    }
2388
2389    #[test]
2390    fn test_extract_package_name_with_pnpm_plus_encoded_scope() {
2391        let path = PathBuf::from(
2392            "/project/node_modules/.pnpm/@mui+material@5.15.0/node_modules/@mui/material/index.js",
2393        );
2394        assert_eq!(
2395            extract_package_name_from_node_modules_path(&path),
2396            Some("@mui/material".to_string())
2397        );
2398    }
2399
2400    #[test]
2401    fn test_extract_package_name_windows_style_path() {
2402        let path = PathBuf::from("/project/node_modules/typescript/lib/tsc.js");
2403        assert_eq!(
2404            extract_package_name_from_node_modules_path(&path),
2405            Some("typescript".to_string())
2406        );
2407    }
2408
2409    #[test]
2410    fn test_try_source_fallback_out_dir() {
2411        let src_path = PathBuf::from("/project/packages/api/src/handler.ts");
2412        let mut path_to_id = FxHashMap::default();
2413        path_to_id.insert(src_path.as_path(), FileId(5));
2414
2415        let out_path = PathBuf::from("/project/packages/api/out/handler.js");
2416        assert_eq!(
2417            try_source_fallback(&out_path, &path_to_id),
2418            Some(FileId(5)),
2419            "out/handler.js should fall back to src/handler.ts"
2420        );
2421    }
2422
2423    #[test]
2424    fn test_try_source_fallback_mts_extension() {
2425        let src_path = PathBuf::from("/project/packages/lib/src/utils.mts");
2426        let mut path_to_id = FxHashMap::default();
2427        path_to_id.insert(src_path.as_path(), FileId(6));
2428
2429        let dist_path = PathBuf::from("/project/packages/lib/dist/utils.mjs");
2430        assert_eq!(
2431            try_source_fallback(&dist_path, &path_to_id),
2432            Some(FileId(6)),
2433            "dist/utils.mjs should fall back to src/utils.mts"
2434        );
2435    }
2436
2437    #[test]
2438    fn test_try_source_fallback_declaration_with_dotted_name() {
2439        let src_path = PathBuf::from("/project/packages/lib/src/x.port.ts");
2440        let mut path_to_id = FxHashMap::default();
2441        path_to_id.insert(src_path.as_path(), FileId(6));
2442
2443        let dist_path = PathBuf::from("/project/packages/lib/dist/x.port.d.ts");
2444        assert_eq!(
2445            try_source_fallback(&dist_path, &path_to_id),
2446            Some(FileId(6)),
2447            "dist/x.port.d.ts should fall back to src/x.port.ts"
2448        );
2449    }
2450
2451    #[test]
2452    fn test_try_source_fallback_module_declaration() {
2453        let src_path = PathBuf::from("/project/packages/lib/src/x.mts");
2454        let mut path_to_id = FxHashMap::default();
2455        path_to_id.insert(src_path.as_path(), FileId(6));
2456
2457        let dist_path = PathBuf::from("/project/packages/lib/dist/x.d.mts");
2458        assert_eq!(
2459            try_source_fallback(&dist_path, &path_to_id),
2460            Some(FileId(6)),
2461            "dist/x.d.mts should fall back to src/x.mts"
2462        );
2463    }
2464
2465    #[test]
2466    fn test_try_source_fallback_commonjs_declaration_in_subdir() {
2467        let src_path = PathBuf::from("/project/packages/lib/src/sub/x.cts");
2468        let mut path_to_id = FxHashMap::default();
2469        path_to_id.insert(src_path.as_path(), FileId(6));
2470
2471        let dist_path = PathBuf::from("/project/packages/lib/dist/sub/x.d.cts");
2472        assert_eq!(
2473            try_source_fallback(&dist_path, &path_to_id),
2474            Some(FileId(6)),
2475            "dist/sub/x.d.cts should fall back to src/sub/x.cts"
2476        );
2477    }
2478
2479    #[test]
2480    fn test_try_source_fallback_declaration_copied_from_src() {
2481        let src_path = PathBuf::from("/project/packages/lib/src/globals.d.ts");
2482        let mut path_to_id = FxHashMap::default();
2483        path_to_id.insert(src_path.as_path(), FileId(6));
2484
2485        let dist_path = PathBuf::from("/project/packages/lib/dist/globals.d.ts");
2486        assert_eq!(
2487            try_source_fallback(&dist_path, &path_to_id),
2488            Some(FileId(6)),
2489            "dist/globals.d.ts should fall back to a copied src/globals.d.ts"
2490        );
2491    }
2492
2493    #[test]
2494    fn test_try_source_fallback_js_with_dotted_name() {
2495        let src_path = PathBuf::from("/project/packages/lib/src/x.port.ts");
2496        let mut path_to_id = FxHashMap::default();
2497        path_to_id.insert(src_path.as_path(), FileId(6));
2498
2499        let dist_path = PathBuf::from("/project/packages/lib/dist/x.port.js");
2500        assert_eq!(
2501            try_source_fallback(&dist_path, &path_to_id),
2502            Some(FileId(6)),
2503            "dist/x.port.js should fall back to src/x.port.ts"
2504        );
2505    }
2506
2507    #[test]
2508    fn test_try_source_fallback_cts_extension() {
2509        let src_path = PathBuf::from("/project/packages/lib/src/config.cts");
2510        let mut path_to_id = FxHashMap::default();
2511        path_to_id.insert(src_path.as_path(), FileId(7));
2512
2513        let dist_path = PathBuf::from("/project/packages/lib/dist/config.cjs");
2514        assert_eq!(
2515            try_source_fallback(&dist_path, &path_to_id),
2516            Some(FileId(7)),
2517            "dist/config.cjs should fall back to src/config.cts"
2518        );
2519    }
2520
2521    #[test]
2522    fn test_try_source_fallback_jsx_extension() {
2523        let src_path = PathBuf::from("/project/packages/ui/src/App.jsx");
2524        let mut path_to_id = FxHashMap::default();
2525        path_to_id.insert(src_path.as_path(), FileId(8));
2526
2527        let build_path = PathBuf::from("/project/packages/ui/build/App.js");
2528        assert_eq!(
2529            try_source_fallback(&build_path, &path_to_id),
2530            Some(FileId(8)),
2531            "build/App.js should fall back to src/App.jsx"
2532        );
2533    }
2534
2535    #[test]
2536    fn test_try_source_fallback_no_file_stem() {
2537        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2538        let dist_path = PathBuf::from("/project/packages/ui/dist/");
2539        assert_eq!(
2540            try_source_fallback(&dist_path, &path_to_id),
2541            None,
2542            "directory path with no file should return None"
2543        );
2544    }
2545
2546    #[test]
2547    fn test_try_source_fallback_esm_subdir() {
2548        let src_path = PathBuf::from("/project/lib/src/index.ts");
2549        let mut path_to_id = FxHashMap::default();
2550        path_to_id.insert(src_path.as_path(), FileId(10));
2551
2552        let dist_path = PathBuf::from("/project/lib/esm/index.mjs");
2553        assert_eq!(
2554            try_source_fallback(&dist_path, &path_to_id),
2555            Some(FileId(10)),
2556            "standalone esm/ directory should fall back to src/"
2557        );
2558    }
2559
2560    #[test]
2561    fn test_try_source_fallback_cjs_subdir() {
2562        let src_path = PathBuf::from("/project/lib/src/index.ts");
2563        let mut path_to_id = FxHashMap::default();
2564        path_to_id.insert(src_path.as_path(), FileId(11));
2565
2566        let cjs_path = PathBuf::from("/project/lib/cjs/index.cjs");
2567        assert_eq!(
2568            try_source_fallback(&cjs_path, &path_to_id),
2569            Some(FileId(11)),
2570            "standalone cjs/ directory should fall back to src/"
2571        );
2572    }
2573
2574    #[test]
2575    fn test_try_pnpm_workspace_fallback_empty_after_pnpm() {
2576        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2577        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2578
2579        let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/node_modules");
2580        assert_eq!(
2581            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2582            None,
2583            "path ending at node_modules with nothing after should return None"
2584        );
2585    }
2586
2587    #[test]
2588    fn test_try_pnpm_workspace_fallback_scoped_package_only_scope() {
2589        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2590        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2591
2592        let pnpm_path =
2593            PathBuf::from("/project/node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope");
2594        assert_eq!(
2595            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2596            None,
2597            "scoped package without full name and no matching workspace should return None"
2598        );
2599    }
2600
2601    #[test]
2602    fn test_try_pnpm_workspace_fallback_no_inner_node_modules() {
2603        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2604        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2605
2606        let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/dist/index.js");
2607        assert_eq!(
2608            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2609            None,
2610            "path without inner node_modules after .pnpm should return None"
2611        );
2612    }
2613
2614    #[test]
2615    fn test_try_pnpm_workspace_fallback_package_without_relative_path() {
2616        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2617        let mut workspace_roots = FxHashMap::default();
2618        let ws_root = PathBuf::from("/project/packages/ui");
2619        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2620
2621        let pnpm_path =
2622            PathBuf::from("/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui");
2623        assert_eq!(
2624            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2625            None,
2626            "path ending at package name with no relative file should return None"
2627        );
2628    }
2629
2630    #[test]
2631    fn test_try_pnpm_workspace_fallback_nested_dist_esm() {
2632        let src_path = PathBuf::from("/project/packages/ui/src/Button.ts");
2633        let mut path_to_id = FxHashMap::default();
2634        path_to_id.insert(src_path.as_path(), FileId(10));
2635
2636        let mut workspace_roots = FxHashMap::default();
2637        let ws_root = PathBuf::from("/project/packages/ui");
2638        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2639
2640        let pnpm_path = PathBuf::from(
2641            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/esm/Button.mjs",
2642        );
2643        assert_eq!(
2644            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2645            Some(FileId(10)),
2646            "pnpm path with nested dist/esm should resolve through source fallback"
2647        );
2648    }
2649
2650    // --- package_map_target: non-object map branches (lines 583-586) ---
2651
2652    #[test]
2653    fn package_map_target_string_value_dot_key() {
2654        // A non-object top-level map with specifier_key "." delegates to
2655        // package_map_match_value immediately.
2656        let map = serde_json::Value::String("./src/index.ts".to_string());
2657        let conds = conditions();
2658        // A string value resolves to Targets.
2659        let result = package_map_target(&map, ".", &conds);
2660        assert!(
2661            matches!(result, PackageMapTarget::Targets(_)),
2662            "string map with '.' key should return Targets"
2663        );
2664    }
2665
2666    #[test]
2667    fn package_map_target_string_value_non_dot_key_no_match() {
2668        // A non-object top-level map with a non-"." specifier returns NoMatch.
2669        let map = serde_json::Value::String("./src/index.ts".to_string());
2670        let conds = conditions();
2671        let result = package_map_target(&map, "./sub", &conds);
2672        assert!(
2673            matches!(result, PackageMapTarget::NoMatch),
2674            "string map with non-dot key should return NoMatch"
2675        );
2676    }
2677
2678    #[test]
2679    fn package_map_target_null_value_dot_key() {
2680        // A null top-level map with "." returns Blocked (null means blocked).
2681        let map = serde_json::Value::Null;
2682        let conds = conditions();
2683        let result = package_map_target(&map, ".", &conds);
2684        assert!(
2685            matches!(result, PackageMapTarget::Blocked),
2686            "null map with '.' key should return Blocked"
2687        );
2688    }
2689
2690    #[test]
2691    fn package_map_target_bool_value_non_dot_key() {
2692        // A bool top-level map with non-dot key is not an object, returns NoMatch.
2693        let map = serde_json::Value::Bool(true);
2694        let conds = conditions();
2695        let result = package_map_target(&map, "./sub", &conds);
2696        assert!(
2697            matches!(result, PackageMapTarget::NoMatch),
2698            "bool map with non-dot key should return NoMatch"
2699        );
2700    }
2701
2702    // --- package_map_target: condition-only object map (lines 592-596) ---
2703
2704    #[test]
2705    fn package_map_target_condition_only_object_dot_key() {
2706        // An object whose keys are all conditions (not "." or "./...") is treated
2707        // as a condition map when specifier_key is ".".
2708        let map = serde_json::json!({
2709            "import": "./src/index.mjs",
2710            "require": "./src/index.cjs"
2711        });
2712        let conds = conditions();
2713        let result = package_map_target(&map, ".", &conds);
2714        assert!(
2715            matches!(result, PackageMapTarget::Targets(_)),
2716            "condition-only object with '.' key should return Targets"
2717        );
2718    }
2719
2720    #[test]
2721    fn package_map_target_condition_only_object_non_dot_key() {
2722        // Same object, but specifier_key != "." returns NoMatch because no
2723        // subpath key like "./foo" exists.
2724        let map = serde_json::json!({
2725            "import": "./src/index.mjs",
2726            "require": "./src/index.cjs"
2727        });
2728        let conds = conditions();
2729        let result = package_map_target(&map, "./nonexistent", &conds);
2730        assert!(
2731            matches!(result, PackageMapTarget::NoMatch),
2732            "condition-only object with non-dot key should return NoMatch"
2733        );
2734    }
2735
2736    // --- resolve_package_map_value: unmatched conditions, bool, null (lines 641-654) ---
2737
2738    #[test]
2739    fn resolve_package_map_value_unmatched_conditions_returns_none() {
2740        // Object whose only key is not in the active condition set returns None.
2741        let value = serde_json::json!({ "browser": "./src/browser.js" });
2742        let conds = conditions(); // does not include "browser"
2743        assert_eq!(
2744            resolve_package_map_value(&value, &conds, None),
2745            None,
2746            "unmatched condition should return None"
2747        );
2748    }
2749
2750    #[test]
2751    fn resolve_package_map_value_bool_returns_none() {
2752        let value = serde_json::Value::Bool(false);
2753        let conds = conditions();
2754        assert_eq!(
2755            resolve_package_map_value(&value, &conds, None),
2756            None,
2757            "bool value should return None"
2758        );
2759    }
2760
2761    #[test]
2762    fn resolve_package_map_value_number_returns_none() {
2763        let value = serde_json::Value::Number(42.into());
2764        let conds = conditions();
2765        assert_eq!(
2766            resolve_package_map_value(&value, &conds, None),
2767            None,
2768            "number value should return None"
2769        );
2770    }
2771
2772    #[test]
2773    fn resolve_package_map_value_null_returns_none() {
2774        let value = serde_json::Value::Null;
2775        let conds = conditions();
2776        assert_eq!(
2777            resolve_package_map_value(&value, &conds, None),
2778            None,
2779            "null value should return None"
2780        );
2781    }
2782
2783    #[test]
2784    fn resolve_package_map_value_array_all_null_returns_none() {
2785        // Array where every element resolves to None yields None.
2786        let value = serde_json::json!([null, false, 42]);
2787        let conds = conditions();
2788        assert_eq!(
2789            resolve_package_map_value(&value, &conds, None),
2790            None,
2791            "array of unresolvable values should return None"
2792        );
2793    }
2794
2795    #[test]
2796    fn resolve_package_map_value_array_with_valid_entry() {
2797        // Array where one element is a valid string target.
2798        let value = serde_json::json!([null, "./src/index.ts"]);
2799        let conds = conditions();
2800        let result = resolve_package_map_value(&value, &conds, None);
2801        assert_eq!(
2802            result,
2803            Some(vec!["./src/index.ts".to_string()]),
2804            "array with a valid string entry should return that entry"
2805        );
2806    }
2807
2808    // --- package_map_pattern_capture: two-star and no-star branches (lines 659-665) ---
2809
2810    #[test]
2811    fn package_map_pattern_capture_no_star_returns_none() {
2812        // A pattern without '*' returns None (no star found).
2813        assert_eq!(
2814            package_map_pattern_capture("./exact", "./exact"),
2815            None,
2816            "pattern with no star should return None"
2817        );
2818    }
2819
2820    #[test]
2821    fn package_map_pattern_capture_two_stars_returns_none() {
2822        // A pattern with more than one '*' returns None.
2823        assert_eq!(
2824            package_map_pattern_capture("./*/*.js", "./foo/bar.js"),
2825            None,
2826            "pattern with two stars should return None"
2827        );
2828    }
2829
2830    #[test]
2831    fn package_map_pattern_capture_single_star_captures() {
2832        // Sanity: the happy path still works after the guard checks.
2833        assert_eq!(
2834            package_map_pattern_capture("./dist/*/index.js", "./dist/utils/index.js"),
2835            Some("utils".to_string()),
2836        );
2837    }
2838
2839    #[test]
2840    fn package_map_pattern_capture_no_prefix_match_returns_none() {
2841        // Specifier does not start with the pattern prefix.
2842        assert_eq!(
2843            package_map_pattern_capture("./lib/*.js", "./src/foo.js"),
2844            None,
2845        );
2846    }
2847
2848    // --- resolve_package_map_target: parent-dir and root-absolute guard (lines 719-721) ---
2849
2850    #[test]
2851    fn resolve_package_map_target_no_dot_slash_prefix_returns_none() {
2852        // A target that does not start with "./" is rejected by strip_prefix.
2853        let root = PathBuf::from("/project/packages/ui");
2854        let pj = fallow_config::PackageJson::default();
2855        with_package_map_ctx(root, Some("@myorg/ui"), pj, &[], |ctx, manifest, _root| {
2856            let result = resolve_package_map_target(ctx, manifest, "src/index.ts", None);
2857            assert_eq!(result, None, "target without './' should return None");
2858        });
2859    }
2860
2861    #[test]
2862    fn resolve_package_map_target_parent_dir_returns_none() {
2863        // A target starting with "../" is rejected as a path escape.
2864        let root = PathBuf::from("/project/packages/ui");
2865        let pj = fallow_config::PackageJson::default();
2866        with_package_map_ctx(root, Some("@myorg/ui"), pj, &[], |ctx, manifest, _root| {
2867            let result = resolve_package_map_target(ctx, manifest, "./../outside/file.ts", None);
2868            assert_eq!(result, None, "parent-dir target should return None");
2869        });
2870    }
2871
2872    #[test]
2873    fn resolve_package_map_target_absolute_path_returns_none() {
2874        // A target starting with "/" after stripping "./" prefix is rejected.
2875        let root = PathBuf::from("/project/packages/ui");
2876        let pj = fallow_config::PackageJson::default();
2877        with_package_map_ctx(root, Some("@myorg/ui"), pj, &[], |ctx, manifest, _root| {
2878            // "./" + "/" -> "/" after strip_prefix("./") which is no-op, but
2879            // an absolute path disguised as ".//abs" yields "/" start after strip.
2880            let result = resolve_package_map_target(ctx, manifest, ".//abs/path.ts", None);
2881            assert_eq!(result, None, "absolute target should return None");
2882        });
2883    }
2884
2885    #[test]
2886    fn resolve_package_map_target_valid_target_hits_raw_path_map() {
2887        // A valid "./" target resolves when the path is in raw_path_to_id.
2888        let root = PathBuf::from("/project/packages/ui");
2889        let src = root.join("src/index.ts");
2890        let pj = fallow_config::PackageJson::default();
2891        with_package_map_ctx(
2892            root,
2893            Some("@myorg/ui"),
2894            pj,
2895            &[(src, FileId(5))],
2896            |ctx, manifest, _root| {
2897                let result = resolve_package_map_target(ctx, manifest, "./src/index.ts", None);
2898                assert_eq!(
2899                    result,
2900                    Some(FileId(5)),
2901                    "valid target in raw_path_to_id should resolve"
2902                );
2903            },
2904        );
2905    }
2906
2907    // --- package_import_source_subpath: variants (lines 673-689) ---
2908
2909    #[test]
2910    fn package_import_source_subpath_strips_hash_and_package_name() {
2911        let manifest = PackageManifestInfo {
2912            root: PathBuf::from("/project"),
2913            canonical_root: PathBuf::from("/project"),
2914            name: Some("my-pkg".to_string()),
2915            package_json: fallow_config::PackageJson::default(),
2916            deno_import_map: Vec::new(),
2917        };
2918        let result = package_import_source_subpath(&manifest, "#my-pkg/utils");
2919        assert_eq!(
2920            result,
2921            Some(PathBuf::from("utils")),
2922            "should strip '#', package name, and '/' separator"
2923        );
2924    }
2925
2926    #[test]
2927    fn package_import_source_subpath_no_package_name_match_keeps_full_subpath() {
2928        // When the specifier after '#' does not start with the package name,
2929        // the full stripped specifier is returned.
2930        let manifest = PackageManifestInfo {
2931            root: PathBuf::from("/project"),
2932            canonical_root: PathBuf::from("/project"),
2933            name: Some("my-pkg".to_string()),
2934            package_json: fallow_config::PackageJson::default(),
2935            deno_import_map: Vec::new(),
2936        };
2937        let result = package_import_source_subpath(&manifest, "#utils");
2938        assert_eq!(
2939            result,
2940            Some(PathBuf::from("utils")),
2941            "without package-name prefix the full subpath should be kept"
2942        );
2943    }
2944
2945    #[test]
2946    fn package_import_source_subpath_empty_hash_returns_none() {
2947        // "#" with nothing after returns None because stripped is empty and
2948        // the empty string is rejected by the is_empty guard.
2949        let manifest = PackageManifestInfo {
2950            root: PathBuf::from("/project"),
2951            canonical_root: PathBuf::from("/project"),
2952            name: Some("my-pkg".to_string()),
2953            package_json: fallow_config::PackageJson::default(),
2954            deno_import_map: Vec::new(),
2955        };
2956        // "#" strips to "", which is_empty is true, so returns None.
2957        let result = package_import_source_subpath(&manifest, "#");
2958        assert_eq!(
2959            result, None,
2960            "specifier '#' with empty body should return None"
2961        );
2962    }
2963
2964    #[test]
2965    fn package_import_source_subpath_no_hash_returns_none() {
2966        // A specifier not starting with '#' returns None.
2967        let manifest = PackageManifestInfo {
2968            root: PathBuf::from("/project"),
2969            canonical_root: PathBuf::from("/project"),
2970            name: Some("my-pkg".to_string()),
2971            package_json: fallow_config::PackageJson::default(),
2972            deno_import_map: Vec::new(),
2973        };
2974        let result = package_import_source_subpath(&manifest, "no-hash");
2975        assert_eq!(result, None, "specifier without '#' should return None");
2976    }
2977
2978    #[test]
2979    fn package_import_source_subpath_no_manifest_name() {
2980        // When the manifest has no name the full stripped specifier is returned.
2981        let manifest = PackageManifestInfo {
2982            root: PathBuf::from("/project"),
2983            canonical_root: PathBuf::from("/project"),
2984            name: None,
2985            package_json: fallow_config::PackageJson::default(),
2986            deno_import_map: Vec::new(),
2987        };
2988        let result = package_import_source_subpath(&manifest, "#internal/helper");
2989        assert_eq!(
2990            result,
2991            Some(PathBuf::from("internal/helper")),
2992            "manifest without name should return the full stripped specifier"
2993        );
2994    }
2995
2996    // --- nearest_package_manifest: deepest selection (lines 691-701) ---
2997
2998    #[test]
2999    fn nearest_package_manifest_returns_deepest_match() {
3000        let root1 = PathBuf::from("/project");
3001        let root2 = PathBuf::from("/project/packages/ui");
3002        let m1 = PackageManifestInfo {
3003            root: root1.clone(),
3004            canonical_root: root1,
3005            name: Some("root".to_string()),
3006            package_json: fallow_config::PackageJson::default(),
3007            deno_import_map: Vec::new(),
3008        };
3009        let m2 = PackageManifestInfo {
3010            root: root2.clone(),
3011            canonical_root: root2,
3012            name: Some("@myorg/ui".to_string()),
3013            package_json: fallow_config::PackageJson::default(),
3014            deno_import_map: Vec::new(),
3015        };
3016        let manifests = [m1, m2];
3017        let from_file = Path::new("/project/packages/ui/src/index.ts");
3018        let result = nearest_package_manifest(&manifests, from_file);
3019        assert_eq!(
3020            result.and_then(|m| m.name.as_deref()),
3021            Some("@myorg/ui"),
3022            "should pick the deepest (longest path) manifest that contains the file"
3023        );
3024    }
3025
3026    #[test]
3027    fn nearest_package_manifest_no_match_returns_none() {
3028        let root = PathBuf::from("/project/packages/ui");
3029        let m = PackageManifestInfo {
3030            root: root.clone(),
3031            canonical_root: root,
3032            name: Some("@myorg/ui".to_string()),
3033            package_json: fallow_config::PackageJson::default(),
3034            deno_import_map: Vec::new(),
3035        };
3036        let manifests = [m];
3037        // File is outside the manifest root.
3038        let from_file = Path::new("/other/project/src/index.ts");
3039        let result = nearest_package_manifest(&manifests, from_file);
3040        assert!(
3041            result.is_none(),
3042            "file outside all manifest roots should return None"
3043        );
3044    }
3045
3046    // --- lookup_internal_file_id: path_to_id fallback (line 832) ---
3047
3048    #[test]
3049    fn lookup_internal_file_id_uses_path_to_id_when_raw_misses() {
3050        // When raw_path_to_id does not contain the path but path_to_id does,
3051        // lookup_internal_file_id should fall back to path_to_id.
3052        let target = PathBuf::from("/project/src/index.ts");
3053        let mut path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
3054        path_to_id.insert(target.as_path(), FileId(99));
3055        let raw_path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
3056        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
3057        let condition_names = conditions();
3058        let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
3059        let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
3060        let tsconfig_cache = TsconfigCache::default();
3061        let canonicalize_cache = CanonicalizeCache::default();
3062        let root = PathBuf::from("/project");
3063        let script_resolver = crate::resolve::specifier::create_script_resolver(&resolver);
3064        let ctx = ResolveContext {
3065            resolver: &resolver,
3066            script_resolver: &script_resolver,
3067            style_resolver: &resolver,
3068            extensions: &[],
3069            path_to_id: &path_to_id,
3070            raw_path_to_id: &raw_path_to_id,
3071            workspace_roots: &workspace_roots,
3072            package_manifests: &[],
3073            has_deno_import_maps: false,
3074            condition_names: &condition_names,
3075            path_aliases: &[],
3076            scss_include_paths: &[],
3077            static_dir_mappings: &[],
3078            framework_static_dir_mappings: &[],
3079            root: &root,
3080            canonical_fallback: None,
3081            tsconfig_warned: &tsconfig_warned,
3082            tsconfig_cache: &tsconfig_cache,
3083            canonicalize_cache: &canonicalize_cache,
3084        };
3085        let result = lookup_internal_file_id(&ctx, &target);
3086        assert_eq!(
3087            result,
3088            Some(FileId(99)),
3089            "should fall back from raw_path_to_id to path_to_id"
3090        );
3091    }
3092
3093    // --- try_scss_partial_fallback: colon guard (line 91) ---
3094
3095    #[test]
3096    fn try_scss_partial_fallback_rejects_colon_specifier() {
3097        // A specifier containing ':' (e.g. a Sass built-in like "sass:math")
3098        // must return None immediately.
3099        let root = PathBuf::from("/project");
3100        let pj = fallow_config::PackageJson::default();
3101        with_package_map_ctx(root.clone(), None, pj, &[], |ctx, _manifest, _r| {
3102            let from_file = root.join("src/main.scss");
3103            let result = try_scss_partial_fallback(ctx, &from_file, "sass:math");
3104            assert!(
3105                result.is_none(),
3106                "specifier with ':' should short-circuit to None"
3107            );
3108        });
3109    }
3110
3111    #[test]
3112    fn try_scss_partial_fallback_rejects_already_partial_filename() {
3113        // A specifier whose filename already starts with '_' (i.e. it's already a
3114        // partial path) must return None immediately.
3115        let root = PathBuf::from("/project");
3116        let pj = fallow_config::PackageJson::default();
3117        with_package_map_ctx(root.clone(), None, pj, &[], |ctx, _manifest, _r| {
3118            let from_file = root.join("src/main.scss");
3119            let result = try_scss_partial_fallback(ctx, &from_file, "./_variables");
3120            assert!(
3121                result.is_none(),
3122                "already-partial filename should short-circuit to None"
3123            );
3124        });
3125    }
3126}