Skip to main content

fallow_graph/resolve/
fallbacks.rs

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