Skip to main content

fallow_graph/
resolve.rs

1//! Import specifier resolution using `oxc_resolver`.
2//!
3//! Resolves all import specifiers across all modules in parallel, mapping each to
4//! an internal file, npm package, or unresolvable target. Includes support for
5//! tsconfig path aliases, pnpm virtual store paths, React Native platform extensions,
6//! and dynamic import pattern matching via glob.
7
8use std::path::{Path, PathBuf};
9
10use rustc_hash::FxHashMap;
11
12use dashmap::DashMap;
13use oxc_resolver::{ResolveOptions, Resolver};
14use rayon::prelude::*;
15
16use fallow_types::discover::{DiscoveredFile, FileId};
17use fallow_types::extract::{ImportInfo, ModuleInfo, ReExportInfo};
18
19/// Thread-safe cache for bare specifier resolutions using lock-free concurrent reads.
20/// Bare specifiers (like `react`, `lodash/merge`) resolve to the same target
21/// regardless of which file imports them (modulo nested `node_modules`, which is rare).
22/// Uses `DashMap` (sharded read-write locks) instead of `Mutex<FxHashMap>` to eliminate
23/// contention under rayon's work-stealing on large projects.
24struct BareSpecifierCache {
25    cache: DashMap<String, ResolveResult>,
26}
27
28impl BareSpecifierCache {
29    fn new() -> Self {
30        Self {
31            cache: DashMap::new(),
32        }
33    }
34
35    fn get(&self, specifier: &str) -> Option<ResolveResult> {
36        self.cache.get(specifier).map(|entry| entry.clone())
37    }
38
39    fn insert(&self, specifier: String, result: ResolveResult) {
40        self.cache.insert(specifier, result);
41    }
42}
43
44/// Result of resolving an import specifier.
45#[derive(Debug, Clone)]
46pub enum ResolveResult {
47    /// Resolved to a file within the project.
48    InternalModule(FileId),
49    /// Resolved to a file outside the project (`node_modules`, `.json`, etc.).
50    ExternalFile(PathBuf),
51    /// Bare specifier — an npm package.
52    NpmPackage(String),
53    /// Could not resolve.
54    Unresolvable(String),
55}
56
57/// A resolved import with its target.
58#[derive(Debug, Clone)]
59pub struct ResolvedImport {
60    /// The original import information.
61    pub info: ImportInfo,
62    /// Where the import resolved to.
63    pub target: ResolveResult,
64}
65
66/// A resolved re-export with its target.
67#[derive(Debug, Clone)]
68pub struct ResolvedReExport {
69    /// The original re-export information.
70    pub info: ReExportInfo,
71    /// Where the re-export source resolved to.
72    pub target: ResolveResult,
73}
74
75/// Fully resolved module with all imports mapped to targets.
76#[derive(Debug)]
77pub struct ResolvedModule {
78    /// Unique file identifier.
79    pub file_id: FileId,
80    /// Absolute path to the module file.
81    pub path: PathBuf,
82    /// All export declarations in this module.
83    pub exports: Vec<fallow_types::extract::ExportInfo>,
84    /// All re-exports with resolved targets.
85    pub re_exports: Vec<ResolvedReExport>,
86    /// All static imports with resolved targets.
87    pub resolved_imports: Vec<ResolvedImport>,
88    /// All dynamic imports with resolved targets.
89    pub resolved_dynamic_imports: Vec<ResolvedImport>,
90    /// Dynamic import patterns matched against discovered files.
91    pub resolved_dynamic_patterns: Vec<(fallow_types::extract::DynamicImportPattern, Vec<FileId>)>,
92    /// Static member accesses (e.g., `Status.Active`).
93    pub member_accesses: Vec<fallow_types::extract::MemberAccess>,
94    /// Identifiers used as whole objects (Object.values, for..in, spread, etc.).
95    pub whole_object_uses: Vec<String>,
96    /// Whether this module uses `CommonJS` exports.
97    pub has_cjs_exports: bool,
98    /// Local names of import bindings that are never referenced in this file.
99    pub unused_import_bindings: Vec<String>,
100}
101
102/// Resolve all imports across all modules in parallel.
103pub fn resolve_all_imports(
104    modules: &[ModuleInfo],
105    files: &[DiscoveredFile],
106    workspaces: &[fallow_config::WorkspaceInfo],
107    active_plugins: &[String],
108    path_aliases: &[(String, String)],
109    root: &Path,
110) -> Vec<ResolvedModule> {
111    // Build workspace name → root index for pnpm store fallback.
112    // Canonicalize roots to match path_to_id (which uses canonical paths).
113    // Without this, macOS /var → /private/var and similar platform symlinks
114    // cause workspace roots to mismatch canonical file paths.
115    let canonical_ws_roots: Vec<PathBuf> = workspaces
116        .par_iter()
117        .map(|ws| ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone()))
118        .collect();
119    let workspace_roots: FxHashMap<&str, &Path> = workspaces
120        .iter()
121        .zip(canonical_ws_roots.iter())
122        .map(|(ws, canonical)| (ws.name.as_str(), canonical.as_path()))
123        .collect();
124
125    // Pre-compute canonical paths ONCE for all files in parallel (avoiding repeated syscalls).
126    // Each canonicalize() is a syscall — parallelizing over rayon reduces wall time.
127    let canonical_paths: Vec<PathBuf> = files
128        .par_iter()
129        .map(|f| f.path.canonicalize().unwrap_or_else(|_| f.path.clone()))
130        .collect();
131
132    // Build path -> FileId index using pre-computed canonical paths
133    let path_to_id: FxHashMap<&Path, FileId> = canonical_paths
134        .iter()
135        .enumerate()
136        .map(|(idx, canonical)| (canonical.as_path(), files[idx].id))
137        .collect();
138
139    // Also index by non-canonical path for fallback lookups
140    let raw_path_to_id: FxHashMap<&Path, FileId> =
141        files.iter().map(|f| (f.path.as_path(), f.id)).collect();
142
143    // FileIds are sequential 0..n, so direct array indexing is faster than FxHashMap.
144    let file_paths: Vec<&Path> = files.iter().map(|f| f.path.as_path()).collect();
145
146    // Create resolver ONCE and share across threads (oxc_resolver::Resolver is Send + Sync)
147    let resolver = create_resolver(active_plugins);
148
149    // Cache for bare specifier resolutions (e.g., `react`, `lodash/merge`)
150    let bare_cache = BareSpecifierCache::new();
151
152    // Resolve in parallel — shared resolver instance
153    modules
154        .par_iter()
155        .filter_map(|module| {
156            let Some(file_path) = file_paths.get(module.file_id.0 as usize) else {
157                tracing::warn!(
158                    file_id = module.file_id.0,
159                    "Skipping module with unknown file_id during resolution"
160                );
161                return None;
162            };
163
164            let resolved_imports: Vec<ResolvedImport> = module
165                .imports
166                .iter()
167                .map(|imp| ResolvedImport {
168                    info: imp.clone(),
169                    target: resolve_specifier(
170                        &resolver,
171                        file_path,
172                        &imp.source,
173                        &path_to_id,
174                        &raw_path_to_id,
175                        &bare_cache,
176                        &workspace_roots,
177                        path_aliases,
178                        root,
179                    ),
180                })
181                .collect();
182
183            let resolved_dynamic_imports: Vec<ResolvedImport> = module
184                .dynamic_imports
185                .iter()
186                .flat_map(|imp| {
187                    let target = resolve_specifier(
188                        &resolver,
189                        file_path,
190                        &imp.source,
191                        &path_to_id,
192                        &raw_path_to_id,
193                        &bare_cache,
194                        &workspace_roots,
195                        path_aliases,
196                        root,
197                    );
198                    if !imp.destructured_names.is_empty() {
199                        // `const { a, b } = await import('./x')` → Named imports
200                        imp.destructured_names
201                            .iter()
202                            .map(|name| ResolvedImport {
203                                info: ImportInfo {
204                                    source: imp.source.clone(),
205                                    imported_name: fallow_types::extract::ImportedName::Named(
206                                        name.clone(),
207                                    ),
208                                    local_name: name.clone(),
209                                    is_type_only: false,
210                                    span: imp.span,
211                                },
212                                target: target.clone(),
213                            })
214                            .collect()
215                    } else if imp.local_name.is_some() {
216                        // `const mod = await import('./x')` → Namespace with local_name
217                        vec![ResolvedImport {
218                            info: ImportInfo {
219                                source: imp.source.clone(),
220                                imported_name: fallow_types::extract::ImportedName::Namespace,
221                                local_name: imp.local_name.clone().unwrap_or_default(),
222                                is_type_only: false,
223                                span: imp.span,
224                            },
225                            target,
226                        }]
227                    } else {
228                        // Side-effect only: `await import('./x')` with no assignment
229                        vec![ResolvedImport {
230                            info: ImportInfo {
231                                source: imp.source.clone(),
232                                imported_name: fallow_types::extract::ImportedName::SideEffect,
233                                local_name: String::new(),
234                                is_type_only: false,
235                                span: imp.span,
236                            },
237                            target,
238                        }]
239                    }
240                })
241                .collect();
242
243            let re_exports: Vec<ResolvedReExport> = module
244                .re_exports
245                .iter()
246                .map(|re| ResolvedReExport {
247                    info: re.clone(),
248                    target: resolve_specifier(
249                        &resolver,
250                        file_path,
251                        &re.source,
252                        &path_to_id,
253                        &raw_path_to_id,
254                        &bare_cache,
255                        &workspace_roots,
256                        path_aliases,
257                        root,
258                    ),
259                })
260                .collect();
261
262            // Also resolve require() calls.
263            // Destructured requires → Named imports; others → Namespace (conservative).
264            let require_imports: Vec<ResolvedImport> = module
265                .require_calls
266                .iter()
267                .flat_map(|req| {
268                    let target = resolve_specifier(
269                        &resolver,
270                        file_path,
271                        &req.source,
272                        &path_to_id,
273                        &raw_path_to_id,
274                        &bare_cache,
275                        &workspace_roots,
276                        path_aliases,
277                        root,
278                    );
279                    if req.destructured_names.is_empty() {
280                        vec![ResolvedImport {
281                            info: ImportInfo {
282                                source: req.source.clone(),
283                                imported_name: fallow_types::extract::ImportedName::Namespace,
284                                local_name: req.local_name.clone().unwrap_or_default(),
285                                is_type_only: false,
286                                span: req.span,
287                            },
288                            target,
289                        }]
290                    } else {
291                        req.destructured_names
292                            .iter()
293                            .map(|name| ResolvedImport {
294                                info: ImportInfo {
295                                    source: req.source.clone(),
296                                    imported_name: fallow_types::extract::ImportedName::Named(
297                                        name.clone(),
298                                    ),
299                                    local_name: name.clone(),
300                                    is_type_only: false,
301                                    span: req.span,
302                                },
303                                target: target.clone(),
304                            })
305                            .collect()
306                    }
307                })
308                .collect();
309
310            let mut all_imports = resolved_imports;
311            all_imports.extend(require_imports);
312
313            // Resolve dynamic import patterns via glob matching against discovered files.
314            // Use pre-computed canonical paths (no syscalls in inner loop).
315            let from_dir = canonical_paths
316                .get(module.file_id.0 as usize)
317                .and_then(|p| p.parent())
318                .unwrap_or(file_path);
319            let resolved_dynamic_patterns: Vec<(
320                fallow_types::extract::DynamicImportPattern,
321                Vec<FileId>,
322            )> = module
323                .dynamic_import_patterns
324                .iter()
325                .filter_map(|pattern| {
326                    let glob_str = make_glob_from_pattern(pattern);
327                    let matcher = globset::Glob::new(&glob_str)
328                        .ok()
329                        .map(|g| g.compile_matcher())?;
330                    let matched: Vec<FileId> = canonical_paths
331                        .iter()
332                        .enumerate()
333                        .filter(|(_idx, canonical)| {
334                            canonical.strip_prefix(from_dir).is_ok_and(|relative| {
335                                let rel_str = format!("./{}", relative.to_string_lossy());
336                                matcher.is_match(&rel_str)
337                            })
338                        })
339                        .map(|(idx, _)| files[idx].id)
340                        .collect();
341                    if matched.is_empty() {
342                        None
343                    } else {
344                        Some((pattern.clone(), matched))
345                    }
346                })
347                .collect();
348
349            Some(ResolvedModule {
350                file_id: module.file_id,
351                path: file_path.to_path_buf(),
352                exports: module.exports.clone(),
353                re_exports,
354                resolved_imports: all_imports,
355                resolved_dynamic_imports,
356                resolved_dynamic_patterns,
357                member_accesses: module.member_accesses.clone(),
358                whole_object_uses: module.whole_object_uses.clone(),
359                has_cjs_exports: module.has_cjs_exports,
360                unused_import_bindings: module.unused_import_bindings.clone(),
361            })
362        })
363        .collect()
364}
365
366/// Check if a bare specifier looks like a path alias rather than an npm package.
367///
368/// Path aliases (e.g., `@/components`, `~/lib`, `#internal`, `~~/utils`) are resolved
369/// via tsconfig.json `paths` or package.json `imports`. They should not be cached
370/// (resolution depends on the importing file's tsconfig context) and should return
371/// `Unresolvable` (not `NpmPackage`) when resolution fails.
372pub fn is_path_alias(specifier: &str) -> bool {
373    // `#` prefix is Node.js imports maps (package.json "imports" field)
374    if specifier.starts_with('#') {
375        return true;
376    }
377    // `~/` and `~~/` prefixes are common alias conventions (e.g., Nuxt, custom tsconfig)
378    if specifier.starts_with("~/") || specifier.starts_with("~~/") {
379        return true;
380    }
381    // `@/` is a very common path alias (e.g., `@/components/Foo`)
382    if specifier.starts_with("@/") {
383        return true;
384    }
385    // npm scoped packages MUST be lowercase (npm registry requirement).
386    // PascalCase `@Scope` or `@Scope/path` patterns are tsconfig path aliases,
387    // not npm packages. E.g., `@Components`, `@Hooks/useApi`, `@Services/auth`.
388    if specifier.starts_with('@') {
389        let scope = specifier.split('/').next().unwrap_or(specifier);
390        if scope.len() > 1 && scope.chars().nth(1).is_some_and(|c| c.is_ascii_uppercase()) {
391            return true;
392        }
393    }
394
395    false
396}
397
398/// React Native platform extension prefixes.
399/// Metro resolves platform-specific files (e.g., `./foo` -> `./foo.web.tsx` on web).
400const RN_PLATFORM_PREFIXES: &[&str] = &[".web", ".ios", ".android", ".native"];
401
402/// Check if React Native or Expo plugins are active.
403fn has_react_native_plugin(active_plugins: &[String]) -> bool {
404    active_plugins
405        .iter()
406        .any(|p| p == "react-native" || p == "expo")
407}
408
409/// Build the resolver extension list, optionally prepending React Native platform
410/// extensions when the RN/Expo plugin is active.
411fn build_extensions(active_plugins: &[String]) -> Vec<String> {
412    let base: Vec<String> = vec![
413        ".ts".into(),
414        ".tsx".into(),
415        ".d.ts".into(),
416        ".d.mts".into(),
417        ".d.cts".into(),
418        ".mts".into(),
419        ".cts".into(),
420        ".js".into(),
421        ".jsx".into(),
422        ".mjs".into(),
423        ".cjs".into(),
424        ".json".into(),
425        ".vue".into(),
426        ".svelte".into(),
427        ".astro".into(),
428        ".mdx".into(),
429        ".css".into(),
430        ".scss".into(),
431    ];
432
433    if has_react_native_plugin(active_plugins) {
434        let source_exts = [".ts", ".tsx", ".js", ".jsx"];
435        let mut rn_extensions: Vec<String> = Vec::new();
436        for platform in RN_PLATFORM_PREFIXES {
437            for ext in &source_exts {
438                rn_extensions.push(format!("{platform}{ext}"));
439            }
440        }
441        rn_extensions.extend(base);
442        rn_extensions
443    } else {
444        base
445    }
446}
447
448/// Build the resolver `condition_names` list, optionally prepending React Native
449/// conditions when the RN/Expo plugin is active.
450fn build_condition_names(active_plugins: &[String]) -> Vec<String> {
451    let mut names = vec![
452        "import".into(),
453        "require".into(),
454        "default".into(),
455        "types".into(),
456        "node".into(),
457    ];
458    if has_react_native_plugin(active_plugins) {
459        names.insert(0, "react-native".into());
460        names.insert(1, "browser".into());
461    }
462    names
463}
464
465/// Create an `oxc_resolver` instance with standard configuration.
466///
467/// When React Native or Expo plugins are active, platform-specific extensions
468/// (e.g., `.web.tsx`, `.ios.ts`) are prepended to the extension list so that
469/// Metro-style platform resolution works correctly.
470fn create_resolver(active_plugins: &[String]) -> Resolver {
471    let mut options = ResolveOptions {
472        extensions: build_extensions(active_plugins),
473        // Support TypeScript's node16/nodenext module resolution where .ts files
474        // are imported with .js extensions (e.g., `import './api.js'` for `api.ts`).
475        extension_alias: vec![
476            (
477                ".js".into(),
478                vec![".ts".into(), ".tsx".into(), ".js".into()],
479            ),
480            (".jsx".into(), vec![".tsx".into(), ".jsx".into()]),
481            (".mjs".into(), vec![".mts".into(), ".mjs".into()]),
482            (".cjs".into(), vec![".cts".into(), ".cjs".into()]),
483        ],
484        condition_names: build_condition_names(active_plugins),
485        main_fields: vec!["module".into(), "main".into()],
486        ..Default::default()
487    };
488
489    // Always use auto-discovery mode so oxc_resolver finds the nearest tsconfig.json
490    // for each file. This is critical for monorepos where workspace packages have
491    // their own tsconfig with path aliases (e.g., `~/*` → `./src/*`). Manual mode
492    // with a root tsconfig only uses that single tsconfig's paths for ALL files,
493    // missing workspace-specific aliases. Auto mode walks up from each file to find
494    // the nearest tsconfig.json and follows `extends` chains, so workspace tsconfigs
495    // that extend a root tsconfig still inherit root-level paths.
496    options.tsconfig = Some(oxc_resolver::TsconfigDiscovery::Auto);
497
498    Resolver::new(options)
499}
500
501/// Resolve a single import specifier to a target.
502#[expect(clippy::too_many_arguments)]
503fn resolve_specifier(
504    resolver: &Resolver,
505    from_file: &Path,
506    specifier: &str,
507    path_to_id: &FxHashMap<&Path, FileId>,
508    raw_path_to_id: &FxHashMap<&Path, FileId>,
509    bare_cache: &BareSpecifierCache,
510    workspace_roots: &FxHashMap<&str, &Path>,
511    path_aliases: &[(String, String)],
512    root: &Path,
513) -> ResolveResult {
514    // URL imports (https://, http://, data:) are valid but can't be resolved locally
515    if specifier.contains("://") || specifier.starts_with("data:") {
516        return ResolveResult::ExternalFile(PathBuf::from(specifier));
517    }
518
519    // Fast path for bare specifiers: check cache first to avoid repeated resolver work.
520    // Path aliases (e.g., `@/components`, `~/lib`) are excluded from caching because
521    // they may resolve differently depending on the importing file's tsconfig context.
522    let is_bare = is_bare_specifier(specifier);
523    let is_alias = is_path_alias(specifier);
524    if is_bare
525        && !is_alias
526        && let Some(cached) = bare_cache.get(specifier)
527    {
528        return cached;
529    }
530
531    // Use resolve_file instead of resolve so that TsconfigDiscovery::Auto works.
532    // oxc_resolver's resolve() ignores Auto tsconfig discovery — only resolve_file()
533    // walks up from the importing file to find the nearest tsconfig.json and apply
534    // its path aliases (e.g., @/ → src/).
535    //
536    // Track whether the resolver succeeded to decide caching strategy. When resolution
537    // fails, the specifier might be a tsconfig path alias (e.g., `@bazam/shared-types`)
538    // that `is_path_alias` didn't detect (lowercase scoped package). Caching the
539    // `NpmPackage` fallback would poison the cache — all subsequent files would skip
540    // resolution and use the wrong result, even if their tsconfig context can resolve it.
541    let (result, resolver_succeeded) = match resolver.resolve_file(from_file, specifier) {
542        Ok(resolved) => {
543            let resolved_path = resolved.path();
544            // Try raw path lookup first (avoids canonicalize syscall in most cases)
545            if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
546                let result = ResolveResult::InternalModule(file_id);
547                // Cache successful resolution for reuse by other files
548                if is_bare && !is_alias {
549                    bare_cache.insert(specifier.to_string(), result.clone());
550                }
551                return result;
552            }
553            // Fall back to canonical path lookup
554            let result = match resolved_path.canonicalize() {
555                Ok(canonical) => {
556                    if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
557                        ResolveResult::InternalModule(file_id)
558                    } else if let Some(file_id) = try_source_fallback(&canonical, path_to_id) {
559                        // Exports map resolved to a built output (e.g., dist/utils.js)
560                        // but the source file (e.g., src/utils.ts) is what we track.
561                        ResolveResult::InternalModule(file_id)
562                    } else if let Some(file_id) =
563                        try_pnpm_workspace_fallback(&canonical, path_to_id, workspace_roots)
564                    {
565                        ResolveResult::InternalModule(file_id)
566                    } else if let Some(pkg_name) =
567                        extract_package_name_from_node_modules_path(&canonical)
568                    {
569                        ResolveResult::NpmPackage(pkg_name)
570                    } else {
571                        ResolveResult::ExternalFile(canonical)
572                    }
573                }
574                Err(_) => {
575                    // Path doesn't exist on disk — try source fallback on the raw path
576                    if let Some(file_id) = try_source_fallback(resolved_path, path_to_id) {
577                        ResolveResult::InternalModule(file_id)
578                    } else if let Some(file_id) =
579                        try_pnpm_workspace_fallback(resolved_path, path_to_id, workspace_roots)
580                    {
581                        ResolveResult::InternalModule(file_id)
582                    } else if let Some(pkg_name) =
583                        extract_package_name_from_node_modules_path(resolved_path)
584                    {
585                        ResolveResult::NpmPackage(pkg_name)
586                    } else {
587                        ResolveResult::ExternalFile(resolved_path.to_path_buf())
588                    }
589                }
590            };
591            (result, true)
592        }
593        Err(_) => {
594            let result = if is_alias {
595                // Try plugin-provided path aliases before giving up.
596                // These substitute import prefixes (e.g., `~/` → `app/`) and re-resolve
597                // as relative imports from the project root.
598                if let Some(resolved) = try_path_alias_fallback(
599                    resolver,
600                    specifier,
601                    path_aliases,
602                    root,
603                    path_to_id,
604                    raw_path_to_id,
605                    workspace_roots,
606                ) {
607                    resolved
608                } else {
609                    // Path aliases that fail resolution are unresolvable, not npm packages.
610                    // Classifying them as NpmPackage would cause false "unlisted dependency" reports.
611                    ResolveResult::Unresolvable(specifier.to_string())
612                }
613            } else if is_bare {
614                let pkg_name = extract_package_name(specifier);
615                ResolveResult::NpmPackage(pkg_name)
616            } else {
617                ResolveResult::Unresolvable(specifier.to_string())
618            };
619            (result, false)
620        }
621    };
622
623    // Cache bare specifier results only when the resolver succeeded.
624    // When resolver.resolve_file returned Ok, the result is authoritative — it either
625    // found the file in node_modules (NpmPackage) or via tsconfig paths (InternalModule).
626    // When it returned Err, the NpmPackage fallback is a guess — another file's tsconfig
627    // context might resolve the same specifier to an internal module. Caching that guess
628    // would prevent correct resolution for all subsequent files.
629    if is_bare && !is_alias && resolver_succeeded {
630        bare_cache.insert(specifier.to_string(), result.clone());
631    }
632
633    result
634}
635
636/// Try resolving a specifier using plugin-provided path aliases.
637///
638/// Substitutes a matching alias prefix (e.g., `~/`) with a directory relative to the
639/// project root (e.g., `app/`) and resolves the resulting path. This handles framework
640/// aliases like Nuxt's `~/`, `~~/`, `#shared/` that aren't defined in tsconfig.json
641/// but map to real filesystem paths.
642fn try_path_alias_fallback(
643    resolver: &Resolver,
644    specifier: &str,
645    path_aliases: &[(String, String)],
646    root: &Path,
647    path_to_id: &FxHashMap<&Path, FileId>,
648    raw_path_to_id: &FxHashMap<&Path, FileId>,
649    workspace_roots: &FxHashMap<&str, &Path>,
650) -> Option<ResolveResult> {
651    for (prefix, replacement) in path_aliases {
652        if !specifier.starts_with(prefix.as_str()) {
653            continue;
654        }
655
656        let remainder = &specifier[prefix.len()..];
657        // Build the substituted path relative to root.
658        // If replacement is empty, remainder is relative to root directly.
659        let substituted = if replacement.is_empty() {
660            format!("./{remainder}")
661        } else {
662            format!("./{replacement}/{remainder}")
663        };
664
665        // Resolve from a synthetic file at the project root so relative paths work.
666        // Use a dummy file path in the root directory.
667        let root_file = root.join("__resolve_root__");
668        if let Ok(resolved) = resolver.resolve_file(&root_file, &substituted) {
669            let resolved_path = resolved.path();
670            // Try raw path lookup first
671            if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
672                return Some(ResolveResult::InternalModule(file_id));
673            }
674            // Fall back to canonical path lookup
675            if let Ok(canonical) = resolved_path.canonicalize() {
676                if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
677                    return Some(ResolveResult::InternalModule(file_id));
678                }
679                if let Some(file_id) = try_source_fallback(&canonical, path_to_id) {
680                    return Some(ResolveResult::InternalModule(file_id));
681                }
682                if let Some(file_id) =
683                    try_pnpm_workspace_fallback(&canonical, path_to_id, workspace_roots)
684                {
685                    return Some(ResolveResult::InternalModule(file_id));
686                }
687                if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
688                    return Some(ResolveResult::NpmPackage(pkg_name));
689                }
690                return Some(ResolveResult::ExternalFile(canonical));
691            }
692        }
693    }
694    None
695}
696
697/// Known output directory names that may appear in exports map targets.
698/// When an exports map points to `./dist/utils.js`, we try replacing these
699/// prefixes with `src/` (the conventional source directory) to find the tracked
700/// source file.
701const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
702
703/// Source extensions to try when mapping a built output file back to source.
704const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
705
706/// Try to map a resolved output path (e.g., `packages/ui/dist/utils.js`) back to
707/// the corresponding source file (e.g., `packages/ui/src/utils.ts`).
708///
709/// This handles cross-workspace imports that go through `exports` maps pointing to
710/// built output directories. Since fallow ignores `dist/`, `build/`, etc. by default,
711/// the resolved path won't be in the file set, but the source file will be.
712///
713/// Nested output subdirectories (e.g., `dist/esm/utils.mjs`, `build/cjs/index.cjs`)
714/// are handled by finding the last output directory component (closest to the file,
715/// avoiding false matches on parent directories) and then walking backwards to collect
716/// all consecutive output directory components before it.
717fn try_source_fallback(resolved: &Path, path_to_id: &FxHashMap<&Path, FileId>) -> Option<FileId> {
718    let components: Vec<_> = resolved.components().collect();
719
720    let is_output_dir = |c: &std::path::Component| -> bool {
721        if let std::path::Component::Normal(s) = c
722            && let Some(name) = s.to_str()
723        {
724            return OUTPUT_DIRS.contains(&name);
725        }
726        false
727    };
728
729    // Find the LAST output directory component (closest to the file).
730    // Using rposition avoids false matches on parent directories that happen to
731    // be named "build", "dist", etc.
732    let last_output_pos = components.iter().rposition(&is_output_dir)?;
733
734    // Walk backwards to find the start of consecutive output directory components.
735    // e.g., for `dist/esm/utils.mjs`, rposition finds `esm`, then we walk back to `dist`.
736    let mut first_output_pos = last_output_pos;
737    while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
738        first_output_pos -= 1;
739    }
740
741    // Build the path prefix (everything before the first consecutive output dir)
742    let prefix: PathBuf = components[..first_output_pos].iter().collect();
743
744    // Build the relative path after the last consecutive output dir
745    let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
746    suffix.file_stem()?; // Ensure the suffix has a filename
747
748    // Try replacing the output dirs with "src" and each source extension
749    for ext in SOURCE_EXTS {
750        let source_candidate = prefix.join("src").join(suffix.with_extension(ext));
751        if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
752            return Some(file_id);
753        }
754    }
755
756    None
757}
758
759/// Extract npm package name from a resolved path inside `node_modules`.
760///
761/// Given a path like `/project/node_modules/react/index.js`, returns `Some("react")`.
762/// Given a path like `/project/node_modules/@scope/pkg/dist/index.js`, returns `Some("@scope/pkg")`.
763/// Returns `None` if the path doesn't contain a `node_modules` segment.
764fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
765    let components: Vec<&str> = path
766        .components()
767        .filter_map(|c| match c {
768            std::path::Component::Normal(s) => s.to_str(),
769            _ => None,
770        })
771        .collect();
772
773    // Find the last "node_modules" component (handles nested node_modules)
774    let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
775
776    let after = &components[nm_idx + 1..];
777    if after.is_empty() {
778        return None;
779    }
780
781    if after[0].starts_with('@') {
782        // Scoped package: @scope/pkg
783        if after.len() >= 2 {
784            Some(format!("{}/{}", after[0], after[1]))
785        } else {
786            Some(after[0].to_string())
787        }
788    } else {
789        Some(after[0].to_string())
790    }
791}
792
793/// Try to map a pnpm virtual store path back to a workspace source file.
794///
795/// When pnpm uses injected dependencies or certain linking strategies, canonical
796/// paths go through `.pnpm`:
797///   `/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/index.js`
798///
799/// This function detects such paths, extracts the package name, checks if it
800/// matches a workspace package, and tries to find the source file in that workspace.
801fn try_pnpm_workspace_fallback(
802    path: &Path,
803    path_to_id: &FxHashMap<&Path, FileId>,
804    workspace_roots: &FxHashMap<&str, &Path>,
805) -> Option<FileId> {
806    // Only relevant for paths containing .pnpm
807    let components: Vec<&str> = path
808        .components()
809        .filter_map(|c| match c {
810            std::path::Component::Normal(s) => s.to_str(),
811            _ => None,
812        })
813        .collect();
814
815    // Find .pnpm component
816    let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
817
818    // After .pnpm, find the inner node_modules (the actual package location)
819    // Structure: .pnpm/<name>@<version>/node_modules/<package>/...
820    let after_pnpm = &components[pnpm_idx + 1..];
821
822    // Find "node_modules" inside the .pnpm directory
823    let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
824    let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
825
826    if after_inner_nm.is_empty() {
827        return None;
828    }
829
830    // Extract package name (handle scoped packages)
831    let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
832        if after_inner_nm.len() >= 2 {
833            (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
834        } else {
835            return None;
836        }
837    } else {
838        (after_inner_nm[0].to_string(), 1)
839    };
840
841    // Check if this package is a workspace package
842    let ws_root = workspace_roots.get(pkg_name.as_str())?;
843
844    // Get the relative path within the package (after the package name components)
845    let relative_parts = &after_inner_nm[pkg_name_components..];
846    if relative_parts.is_empty() {
847        return None;
848    }
849
850    let relative_path: PathBuf = relative_parts.iter().collect();
851
852    // Try direct file lookup in workspace root
853    let direct = ws_root.join(&relative_path);
854    if let Some(&file_id) = path_to_id.get(direct.as_path()) {
855        return Some(file_id);
856    }
857
858    // Try source fallback (dist/ → src/ etc.) within the workspace
859    try_source_fallback(&direct, path_to_id)
860}
861
862/// Convert a `DynamicImportPattern` to a glob string for file matching.
863fn make_glob_from_pattern(pattern: &fallow_types::extract::DynamicImportPattern) -> String {
864    // If the prefix already contains glob characters (from import.meta.glob), use as-is
865    if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
866        return pattern.prefix.clone();
867    }
868    pattern.suffix.as_ref().map_or_else(
869        || format!("{}*", pattern.prefix),
870        |suffix| format!("{}*{}", pattern.prefix, suffix),
871    )
872}
873
874/// Check if a specifier is a bare specifier (npm package or Node.js imports map entry).
875fn is_bare_specifier(specifier: &str) -> bool {
876    !specifier.starts_with('.')
877        && !specifier.starts_with('/')
878        && !specifier.contains("://")
879        && !specifier.starts_with("data:")
880}
881
882/// Extract the npm package name from a specifier.
883/// `@scope/pkg/foo/bar` -> `@scope/pkg`
884/// `lodash/merge` -> `lodash`
885pub fn extract_package_name(specifier: &str) -> String {
886    if specifier.starts_with('@') {
887        let parts: Vec<&str> = specifier.splitn(3, '/').collect();
888        if parts.len() >= 2 {
889            format!("{}/{}", parts[0], parts[1])
890        } else {
891            specifier.to_string()
892        }
893    } else {
894        specifier.split('/').next().unwrap_or(specifier).to_string()
895    }
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901
902    #[test]
903    fn test_extract_package_name() {
904        assert_eq!(extract_package_name("react"), "react");
905        assert_eq!(extract_package_name("lodash/merge"), "lodash");
906        assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
907        assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
908    }
909
910    #[test]
911    fn test_is_bare_specifier() {
912        assert!(is_bare_specifier("react"));
913        assert!(is_bare_specifier("@scope/pkg"));
914        assert!(is_bare_specifier("#internal/module"));
915        assert!(!is_bare_specifier("./utils"));
916        assert!(!is_bare_specifier("../lib"));
917        assert!(!is_bare_specifier("/absolute"));
918    }
919
920    #[test]
921    fn test_extract_package_name_from_node_modules_path_regular() {
922        let path = PathBuf::from("/project/node_modules/react/index.js");
923        assert_eq!(
924            extract_package_name_from_node_modules_path(&path),
925            Some("react".to_string())
926        );
927    }
928
929    #[test]
930    fn test_extract_package_name_from_node_modules_path_scoped() {
931        let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
932        assert_eq!(
933            extract_package_name_from_node_modules_path(&path),
934            Some("@babel/core".to_string())
935        );
936    }
937
938    #[test]
939    fn test_extract_package_name_from_node_modules_path_nested() {
940        // Nested node_modules: should use the last (innermost) one
941        let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
942        assert_eq!(
943            extract_package_name_from_node_modules_path(&path),
944            Some("pkg-b".to_string())
945        );
946    }
947
948    #[test]
949    fn test_extract_package_name_from_node_modules_path_deep_subpath() {
950        let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
951        assert_eq!(
952            extract_package_name_from_node_modules_path(&path),
953            Some("react-dom".to_string())
954        );
955    }
956
957    #[test]
958    fn test_extract_package_name_from_node_modules_path_no_node_modules() {
959        let path = PathBuf::from("/project/src/components/Button.tsx");
960        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
961    }
962
963    #[test]
964    fn test_extract_package_name_from_node_modules_path_just_node_modules() {
965        let path = PathBuf::from("/project/node_modules");
966        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
967    }
968
969    #[test]
970    fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
971        // Edge case: path ends at scope without package name
972        let path = PathBuf::from("/project/node_modules/@scope");
973        assert_eq!(
974            extract_package_name_from_node_modules_path(&path),
975            Some("@scope".to_string())
976        );
977    }
978
979    #[test]
980    fn test_resolve_specifier_node_modules_returns_npm_package() {
981        // When oxc_resolver resolves to a node_modules path that is NOT in path_to_id,
982        // it should return NpmPackage instead of ExternalFile.
983        // We can't easily test resolve_specifier directly without a real resolver,
984        // but the extract_package_name_from_node_modules_path function covers the
985        // core logic that was missing.
986        let path =
987            PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
988        assert_eq!(
989            extract_package_name_from_node_modules_path(&path),
990            Some("styled-components".to_string())
991        );
992
993        let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
994        assert_eq!(
995            extract_package_name_from_node_modules_path(&path),
996            Some("next".to_string())
997        );
998    }
999
1000    #[test]
1001    fn test_try_source_fallback_dist_to_src() {
1002        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1003        let mut path_to_id = FxHashMap::default();
1004        path_to_id.insert(src_path.as_path(), FileId(0));
1005
1006        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1007        assert_eq!(
1008            try_source_fallback(&dist_path, &path_to_id),
1009            Some(FileId(0)),
1010            "dist/utils.js should fall back to src/utils.ts"
1011        );
1012    }
1013
1014    #[test]
1015    fn test_try_source_fallback_build_to_src() {
1016        let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1017        let mut path_to_id = FxHashMap::default();
1018        path_to_id.insert(src_path.as_path(), FileId(1));
1019
1020        let build_path = PathBuf::from("/project/packages/core/build/index.js");
1021        assert_eq!(
1022            try_source_fallback(&build_path, &path_to_id),
1023            Some(FileId(1)),
1024            "build/index.js should fall back to src/index.tsx"
1025        );
1026    }
1027
1028    #[test]
1029    fn test_try_source_fallback_no_match() {
1030        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1031
1032        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1033        assert_eq!(
1034            try_source_fallback(&dist_path, &path_to_id),
1035            None,
1036            "should return None when no source file exists"
1037        );
1038    }
1039
1040    #[test]
1041    fn test_try_source_fallback_non_output_dir() {
1042        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1043        let mut path_to_id = FxHashMap::default();
1044        path_to_id.insert(src_path.as_path(), FileId(0));
1045
1046        // A path that's not in an output directory should not trigger fallback
1047        let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1048        assert_eq!(
1049            try_source_fallback(&normal_path, &path_to_id),
1050            None,
1051            "non-output directory path should not trigger fallback"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_try_source_fallback_nested_path() {
1057        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1058        let mut path_to_id = FxHashMap::default();
1059        path_to_id.insert(src_path.as_path(), FileId(2));
1060
1061        let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1062        assert_eq!(
1063            try_source_fallback(&dist_path, &path_to_id),
1064            Some(FileId(2)),
1065            "nested dist path should fall back to nested src path"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_try_source_fallback_nested_dist_esm() {
1071        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1072        let mut path_to_id = FxHashMap::default();
1073        path_to_id.insert(src_path.as_path(), FileId(0));
1074
1075        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1076        assert_eq!(
1077            try_source_fallback(&dist_path, &path_to_id),
1078            Some(FileId(0)),
1079            "dist/esm/utils.mjs should fall back to src/utils.ts"
1080        );
1081    }
1082
1083    #[test]
1084    fn test_try_source_fallback_nested_build_cjs() {
1085        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1086        let mut path_to_id = FxHashMap::default();
1087        path_to_id.insert(src_path.as_path(), FileId(1));
1088
1089        let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1090        assert_eq!(
1091            try_source_fallback(&build_path, &path_to_id),
1092            Some(FileId(1)),
1093            "build/cjs/index.cjs should fall back to src/index.ts"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_try_source_fallback_nested_dist_esm_deep_path() {
1099        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1100        let mut path_to_id = FxHashMap::default();
1101        path_to_id.insert(src_path.as_path(), FileId(2));
1102
1103        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1104        assert_eq!(
1105            try_source_fallback(&dist_path, &path_to_id),
1106            Some(FileId(2)),
1107            "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1108        );
1109    }
1110
1111    #[test]
1112    fn test_try_source_fallback_triple_nested_output_dirs() {
1113        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1114        let mut path_to_id = FxHashMap::default();
1115        path_to_id.insert(src_path.as_path(), FileId(0));
1116
1117        let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1118        assert_eq!(
1119            try_source_fallback(&dist_path, &path_to_id),
1120            Some(FileId(0)),
1121            "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1122        );
1123    }
1124
1125    #[test]
1126    fn test_try_source_fallback_parent_dir_named_build() {
1127        let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1128        let mut path_to_id = FxHashMap::default();
1129        path_to_id.insert(src_path.as_path(), FileId(0));
1130
1131        let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1132        assert_eq!(
1133            try_source_fallback(&dist_path, &path_to_id),
1134            Some(FileId(0)),
1135            "should resolve dist/ within project, not match parent 'build' dir"
1136        );
1137    }
1138
1139    #[test]
1140    fn test_pnpm_store_path_extract_package_name() {
1141        // pnpm virtual store paths should correctly extract package name
1142        let path =
1143            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1144        assert_eq!(
1145            extract_package_name_from_node_modules_path(&path),
1146            Some("react".to_string())
1147        );
1148    }
1149
1150    #[test]
1151    fn test_pnpm_store_path_scoped_package() {
1152        let path = PathBuf::from(
1153            "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1154        );
1155        assert_eq!(
1156            extract_package_name_from_node_modules_path(&path),
1157            Some("@babel/core".to_string())
1158        );
1159    }
1160
1161    #[test]
1162    fn test_pnpm_store_path_with_peer_deps() {
1163        let path = PathBuf::from(
1164            "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
1165        );
1166        assert_eq!(
1167            extract_package_name_from_node_modules_path(&path),
1168            Some("webpack".to_string())
1169        );
1170    }
1171
1172    #[test]
1173    fn test_try_pnpm_workspace_fallback_dist_to_src() {
1174        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1175        let mut path_to_id = FxHashMap::default();
1176        path_to_id.insert(src_path.as_path(), FileId(0));
1177
1178        let mut workspace_roots = FxHashMap::default();
1179        let ws_root = PathBuf::from("/project/packages/ui");
1180        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1181
1182        // pnpm virtual store path with dist/ output
1183        let pnpm_path = PathBuf::from(
1184            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
1185        );
1186        assert_eq!(
1187            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1188            Some(FileId(0)),
1189            ".pnpm workspace path should fall back to src/utils.ts"
1190        );
1191    }
1192
1193    #[test]
1194    fn test_try_pnpm_workspace_fallback_direct_source() {
1195        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1196        let mut path_to_id = FxHashMap::default();
1197        path_to_id.insert(src_path.as_path(), FileId(1));
1198
1199        let mut workspace_roots = FxHashMap::default();
1200        let ws_root = PathBuf::from("/project/packages/core");
1201        workspace_roots.insert("@myorg/core", ws_root.as_path());
1202
1203        // pnpm path pointing directly to src/
1204        let pnpm_path = PathBuf::from(
1205            "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
1206        );
1207        assert_eq!(
1208            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1209            Some(FileId(1)),
1210            ".pnpm workspace path with src/ should resolve directly"
1211        );
1212    }
1213
1214    #[test]
1215    fn test_try_pnpm_workspace_fallback_non_workspace_package() {
1216        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1217
1218        let mut workspace_roots = FxHashMap::default();
1219        let ws_root = PathBuf::from("/project/packages/ui");
1220        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1221
1222        // External package (not a workspace) — should return None
1223        let pnpm_path =
1224            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1225        assert_eq!(
1226            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1227            None,
1228            "non-workspace package in .pnpm should return None"
1229        );
1230    }
1231
1232    #[test]
1233    fn test_try_pnpm_workspace_fallback_unscoped_package() {
1234        let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
1235        let mut path_to_id = FxHashMap::default();
1236        path_to_id.insert(src_path.as_path(), FileId(2));
1237
1238        let mut workspace_roots = FxHashMap::default();
1239        let ws_root = PathBuf::from("/project/packages/utils");
1240        workspace_roots.insert("my-utils", ws_root.as_path());
1241
1242        // Unscoped workspace package in pnpm store
1243        let pnpm_path = PathBuf::from(
1244            "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
1245        );
1246        assert_eq!(
1247            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1248            Some(FileId(2)),
1249            "unscoped workspace package in .pnpm should resolve"
1250        );
1251    }
1252
1253    #[test]
1254    fn test_try_pnpm_workspace_fallback_nested_path() {
1255        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1256        let mut path_to_id = FxHashMap::default();
1257        path_to_id.insert(src_path.as_path(), FileId(3));
1258
1259        let mut workspace_roots = FxHashMap::default();
1260        let ws_root = PathBuf::from("/project/packages/ui");
1261        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1262
1263        // Nested path within the package
1264        let pnpm_path = PathBuf::from(
1265            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1266        );
1267        assert_eq!(
1268            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1269            Some(FileId(3)),
1270            "nested .pnpm workspace path should resolve through source fallback"
1271        );
1272    }
1273
1274    #[test]
1275    fn test_try_pnpm_workspace_fallback_no_pnpm() {
1276        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1277        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1278
1279        // Regular path without .pnpm — should return None immediately
1280        let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1281        assert_eq!(
1282            try_pnpm_workspace_fallback(&regular_path, &path_to_id, &workspace_roots),
1283            None,
1284        );
1285    }
1286
1287    #[test]
1288    fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1289        let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1290        let mut path_to_id = FxHashMap::default();
1291        path_to_id.insert(src_path.as_path(), FileId(4));
1292
1293        let mut workspace_roots = FxHashMap::default();
1294        let ws_root = PathBuf::from("/project/packages/ui");
1295        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1296
1297        // pnpm path with peer dependency suffix
1298        let pnpm_path = PathBuf::from(
1299            "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1300        );
1301        assert_eq!(
1302            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1303            Some(FileId(4)),
1304            ".pnpm path with peer dep suffix should still resolve"
1305        );
1306    }
1307
1308    #[test]
1309    fn test_has_react_native_plugin_active() {
1310        let plugins = vec!["react-native".to_string(), "typescript".to_string()];
1311        assert!(has_react_native_plugin(&plugins));
1312    }
1313
1314    #[test]
1315    fn test_has_expo_plugin_active() {
1316        let plugins = vec!["expo".to_string(), "typescript".to_string()];
1317        assert!(has_react_native_plugin(&plugins));
1318    }
1319
1320    #[test]
1321    fn test_has_react_native_plugin_inactive() {
1322        let plugins = vec!["nextjs".to_string(), "typescript".to_string()];
1323        assert!(!has_react_native_plugin(&plugins));
1324    }
1325
1326    #[test]
1327    fn test_rn_platform_extensions_prepended() {
1328        let no_rn = build_extensions(&[]);
1329        let rn_plugins = vec!["react-native".to_string()];
1330        let with_rn = build_extensions(&rn_plugins);
1331
1332        // Without RN, the first extension should be .ts
1333        assert_eq!(no_rn[0], ".ts");
1334
1335        // With RN, platform extensions should come first
1336        assert_eq!(with_rn[0], ".web.ts");
1337        assert_eq!(with_rn[1], ".web.tsx");
1338        assert_eq!(with_rn[2], ".web.js");
1339        assert_eq!(with_rn[3], ".web.jsx");
1340
1341        // Verify all 4 platforms (web, ios, android, native) x 4 exts = 16
1342        assert!(with_rn.len() > no_rn.len());
1343        assert_eq!(
1344            with_rn.len(),
1345            no_rn.len() + 16,
1346            "should add 16 platform extensions (4 platforms x 4 exts)"
1347        );
1348    }
1349
1350    #[test]
1351    fn test_rn_condition_names_prepended() {
1352        let no_rn = build_condition_names(&[]);
1353        let rn_plugins = vec!["react-native".to_string()];
1354        let with_rn = build_condition_names(&rn_plugins);
1355
1356        // Without RN, first condition should be "import"
1357        assert_eq!(no_rn[0], "import");
1358
1359        // With RN, "react-native" and "browser" should be prepended
1360        assert_eq!(with_rn[0], "react-native");
1361        assert_eq!(with_rn[1], "browser");
1362        assert_eq!(with_rn[2], "import");
1363    }
1364
1365    mod proptests {
1366        use super::*;
1367        use proptest::prelude::*;
1368
1369        proptest! {
1370            /// Any specifier starting with `.` or `/` must NOT be classified as a bare specifier.
1371            #[test]
1372            fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1373                let dot = format!(".{suffix}");
1374                let slash = format!("/{suffix}");
1375                prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
1376                prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
1377            }
1378
1379            /// Scoped packages (@scope/pkg) should extract exactly `@scope/pkg` — two segments.
1380            #[test]
1381            fn scoped_package_name_has_two_segments(
1382                scope in "[a-z][a-z0-9-]{0,20}",
1383                pkg in "[a-z][a-z0-9-]{0,20}",
1384                subpath in "(/[a-z0-9-]{1,20}){0,3}",
1385            ) {
1386                let specifier = format!("@{scope}/{pkg}{subpath}");
1387                let extracted = extract_package_name(&specifier);
1388                let expected = format!("@{scope}/{pkg}");
1389                prop_assert_eq!(extracted, expected);
1390            }
1391
1392            /// Unscoped packages should extract exactly the first path segment.
1393            #[test]
1394            fn unscoped_package_name_is_first_segment(
1395                pkg in "[a-z][a-z0-9-]{0,30}",
1396                subpath in "(/[a-z0-9-]{1,20}){0,3}",
1397            ) {
1398                let specifier = format!("{pkg}{subpath}");
1399                let extracted = extract_package_name(&specifier);
1400                prop_assert_eq!(extracted, pkg);
1401            }
1402
1403            /// is_bare_specifier and is_path_alias should never panic on arbitrary strings.
1404            #[test]
1405            fn bare_specifier_and_path_alias_no_panic(s in "[a-zA-Z0-9@#~/._-]{1,100}") {
1406                let _ = is_bare_specifier(&s);
1407                let _ = is_path_alias(&s);
1408            }
1409
1410            /// `@/` prefix should always be detected as a path alias.
1411            #[test]
1412            fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1413                let specifier = format!("@/{suffix}");
1414                prop_assert!(is_path_alias(&specifier));
1415            }
1416
1417            /// `~/` prefix should always be detected as a path alias.
1418            #[test]
1419            fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1420                let specifier = format!("~/{suffix}");
1421                prop_assert!(is_path_alias(&specifier));
1422            }
1423
1424            /// `#` prefix should always be detected as a path alias (Node.js imports map).
1425            #[test]
1426            fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1427                let specifier = format!("#{suffix}");
1428                prop_assert!(is_path_alias(&specifier));
1429            }
1430
1431            /// Extracted package name from node_modules path should never be empty.
1432            #[test]
1433            fn node_modules_package_name_never_empty(
1434                pkg in "[a-z][a-z0-9-]{0,20}",
1435                file in "[a-z]{1,10}\\.(js|ts|mjs)",
1436            ) {
1437                let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
1438                if let Some(name) = extract_package_name_from_node_modules_path(&path) {
1439                    prop_assert!(!name.is_empty());
1440                }
1441            }
1442        }
1443    }
1444}