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