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