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