Skip to main content

fallow_graph/resolve/
fallbacks.rs

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