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    let result = match resolver.resolve_file(from_file, specifier) {
536        Ok(resolved) => {
537            let resolved_path = resolved.path();
538            // Try raw path lookup first (avoids canonicalize syscall in most cases)
539            if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
540                return ResolveResult::InternalModule(file_id);
541            }
542            // Fall back to canonical path lookup
543            match resolved_path.canonicalize() {
544                Ok(canonical) => {
545                    if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
546                        ResolveResult::InternalModule(file_id)
547                    } else if let Some(file_id) = try_source_fallback(&canonical, path_to_id) {
548                        // Exports map resolved to a built output (e.g., dist/utils.js)
549                        // but the source file (e.g., src/utils.ts) is what we track.
550                        ResolveResult::InternalModule(file_id)
551                    } else if let Some(file_id) =
552                        try_pnpm_workspace_fallback(&canonical, path_to_id, workspace_roots)
553                    {
554                        ResolveResult::InternalModule(file_id)
555                    } else if let Some(pkg_name) =
556                        extract_package_name_from_node_modules_path(&canonical)
557                    {
558                        ResolveResult::NpmPackage(pkg_name)
559                    } else {
560                        ResolveResult::ExternalFile(canonical)
561                    }
562                }
563                Err(_) => {
564                    // Path doesn't exist on disk — try source fallback on the raw path
565                    if let Some(file_id) = try_source_fallback(resolved_path, path_to_id) {
566                        ResolveResult::InternalModule(file_id)
567                    } else if let Some(file_id) =
568                        try_pnpm_workspace_fallback(resolved_path, path_to_id, workspace_roots)
569                    {
570                        ResolveResult::InternalModule(file_id)
571                    } else if let Some(pkg_name) =
572                        extract_package_name_from_node_modules_path(resolved_path)
573                    {
574                        ResolveResult::NpmPackage(pkg_name)
575                    } else {
576                        ResolveResult::ExternalFile(resolved_path.to_path_buf())
577                    }
578                }
579            }
580        }
581        Err(_) => {
582            if is_alias {
583                // Try plugin-provided path aliases before giving up.
584                // These substitute import prefixes (e.g., `~/` → `app/`) and re-resolve
585                // as relative imports from the project root.
586                if let Some(resolved) = try_path_alias_fallback(
587                    resolver,
588                    specifier,
589                    path_aliases,
590                    root,
591                    path_to_id,
592                    raw_path_to_id,
593                    workspace_roots,
594                ) {
595                    resolved
596                } else {
597                    // Path aliases that fail resolution are unresolvable, not npm packages.
598                    // Classifying them as NpmPackage would cause false "unlisted dependency" reports.
599                    ResolveResult::Unresolvable(specifier.to_string())
600                }
601            } else if is_bare {
602                let pkg_name = extract_package_name(specifier);
603                ResolveResult::NpmPackage(pkg_name)
604            } else {
605                ResolveResult::Unresolvable(specifier.to_string())
606            }
607        }
608    };
609
610    // Cache bare specifier results (NpmPackage or failed resolutions) for reuse.
611    // Path aliases are excluded — they resolve relative to the importing file's tsconfig.
612    if is_bare && !is_alias {
613        bare_cache.insert(specifier.to_string(), result.clone());
614    }
615
616    result
617}
618
619/// Try resolving a specifier using plugin-provided path aliases.
620///
621/// Substitutes a matching alias prefix (e.g., `~/`) with a directory relative to the
622/// project root (e.g., `app/`) and resolves the resulting path. This handles framework
623/// aliases like Nuxt's `~/`, `~~/`, `#shared/` that aren't defined in tsconfig.json
624/// but map to real filesystem paths.
625fn try_path_alias_fallback(
626    resolver: &Resolver,
627    specifier: &str,
628    path_aliases: &[(String, String)],
629    root: &Path,
630    path_to_id: &FxHashMap<&Path, FileId>,
631    raw_path_to_id: &FxHashMap<&Path, FileId>,
632    workspace_roots: &FxHashMap<&str, &Path>,
633) -> Option<ResolveResult> {
634    for (prefix, replacement) in path_aliases {
635        if !specifier.starts_with(prefix.as_str()) {
636            continue;
637        }
638
639        let remainder = &specifier[prefix.len()..];
640        // Build the substituted path relative to root.
641        // If replacement is empty, remainder is relative to root directly.
642        let substituted = if replacement.is_empty() {
643            format!("./{remainder}")
644        } else {
645            format!("./{replacement}/{remainder}")
646        };
647
648        // Resolve from a synthetic file at the project root so relative paths work.
649        // Use a dummy file path in the root directory.
650        let root_file = root.join("__resolve_root__");
651        if let Ok(resolved) = resolver.resolve_file(&root_file, &substituted) {
652            let resolved_path = resolved.path();
653            // Try raw path lookup first
654            if let Some(&file_id) = raw_path_to_id.get(resolved_path) {
655                return Some(ResolveResult::InternalModule(file_id));
656            }
657            // Fall back to canonical path lookup
658            if let Ok(canonical) = resolved_path.canonicalize() {
659                if let Some(&file_id) = path_to_id.get(canonical.as_path()) {
660                    return Some(ResolveResult::InternalModule(file_id));
661                }
662                if let Some(file_id) = try_source_fallback(&canonical, path_to_id) {
663                    return Some(ResolveResult::InternalModule(file_id));
664                }
665                if let Some(file_id) =
666                    try_pnpm_workspace_fallback(&canonical, path_to_id, workspace_roots)
667                {
668                    return Some(ResolveResult::InternalModule(file_id));
669                }
670                if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
671                    return Some(ResolveResult::NpmPackage(pkg_name));
672                }
673                return Some(ResolveResult::ExternalFile(canonical));
674            }
675        }
676    }
677    None
678}
679
680/// Known output directory names that may appear in exports map targets.
681/// When an exports map points to `./dist/utils.js`, we try replacing these
682/// prefixes with `src/` (the conventional source directory) to find the tracked
683/// source file.
684const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
685
686/// Source extensions to try when mapping a built output file back to source.
687const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
688
689/// Try to map a resolved output path (e.g., `packages/ui/dist/utils.js`) back to
690/// the corresponding source file (e.g., `packages/ui/src/utils.ts`).
691///
692/// This handles cross-workspace imports that go through `exports` maps pointing to
693/// built output directories. Since fallow ignores `dist/`, `build/`, etc. by default,
694/// the resolved path won't be in the file set, but the source file will be.
695///
696/// Nested output subdirectories (e.g., `dist/esm/utils.mjs`, `build/cjs/index.cjs`)
697/// are handled by finding the last output directory component (closest to the file,
698/// avoiding false matches on parent directories) and then walking backwards to collect
699/// all consecutive output directory components before it.
700fn try_source_fallback(resolved: &Path, path_to_id: &FxHashMap<&Path, FileId>) -> Option<FileId> {
701    let components: Vec<_> = resolved.components().collect();
702
703    let is_output_dir = |c: &std::path::Component| -> bool {
704        if let std::path::Component::Normal(s) = c
705            && let Some(name) = s.to_str()
706        {
707            return OUTPUT_DIRS.contains(&name);
708        }
709        false
710    };
711
712    // Find the LAST output directory component (closest to the file).
713    // Using rposition avoids false matches on parent directories that happen to
714    // be named "build", "dist", etc.
715    let last_output_pos = components.iter().rposition(&is_output_dir)?;
716
717    // Walk backwards to find the start of consecutive output directory components.
718    // e.g., for `dist/esm/utils.mjs`, rposition finds `esm`, then we walk back to `dist`.
719    let mut first_output_pos = last_output_pos;
720    while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
721        first_output_pos -= 1;
722    }
723
724    // Build the path prefix (everything before the first consecutive output dir)
725    let prefix: PathBuf = components[..first_output_pos].iter().collect();
726
727    // Build the relative path after the last consecutive output dir
728    let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
729    suffix.file_stem()?; // Ensure the suffix has a filename
730
731    // Try replacing the output dirs with "src" and each source extension
732    for ext in SOURCE_EXTS {
733        let source_candidate = prefix.join("src").join(suffix.with_extension(ext));
734        if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
735            return Some(file_id);
736        }
737    }
738
739    None
740}
741
742/// Extract npm package name from a resolved path inside `node_modules`.
743///
744/// Given a path like `/project/node_modules/react/index.js`, returns `Some("react")`.
745/// Given a path like `/project/node_modules/@scope/pkg/dist/index.js`, returns `Some("@scope/pkg")`.
746/// Returns `None` if the path doesn't contain a `node_modules` segment.
747fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
748    let components: Vec<&str> = path
749        .components()
750        .filter_map(|c| match c {
751            std::path::Component::Normal(s) => s.to_str(),
752            _ => None,
753        })
754        .collect();
755
756    // Find the last "node_modules" component (handles nested node_modules)
757    let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
758
759    let after = &components[nm_idx + 1..];
760    if after.is_empty() {
761        return None;
762    }
763
764    if after[0].starts_with('@') {
765        // Scoped package: @scope/pkg
766        if after.len() >= 2 {
767            Some(format!("{}/{}", after[0], after[1]))
768        } else {
769            Some(after[0].to_string())
770        }
771    } else {
772        Some(after[0].to_string())
773    }
774}
775
776/// Try to map a pnpm virtual store path back to a workspace source file.
777///
778/// When pnpm uses injected dependencies or certain linking strategies, canonical
779/// paths go through `.pnpm`:
780///   `/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/index.js`
781///
782/// This function detects such paths, extracts the package name, checks if it
783/// matches a workspace package, and tries to find the source file in that workspace.
784fn try_pnpm_workspace_fallback(
785    path: &Path,
786    path_to_id: &FxHashMap<&Path, FileId>,
787    workspace_roots: &FxHashMap<&str, &Path>,
788) -> Option<FileId> {
789    // Only relevant for paths containing .pnpm
790    let components: Vec<&str> = path
791        .components()
792        .filter_map(|c| match c {
793            std::path::Component::Normal(s) => s.to_str(),
794            _ => None,
795        })
796        .collect();
797
798    // Find .pnpm component
799    let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
800
801    // After .pnpm, find the inner node_modules (the actual package location)
802    // Structure: .pnpm/<name>@<version>/node_modules/<package>/...
803    let after_pnpm = &components[pnpm_idx + 1..];
804
805    // Find "node_modules" inside the .pnpm directory
806    let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
807    let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
808
809    if after_inner_nm.is_empty() {
810        return None;
811    }
812
813    // Extract package name (handle scoped packages)
814    let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
815        if after_inner_nm.len() >= 2 {
816            (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
817        } else {
818            return None;
819        }
820    } else {
821        (after_inner_nm[0].to_string(), 1)
822    };
823
824    // Check if this package is a workspace package
825    let ws_root = workspace_roots.get(pkg_name.as_str())?;
826
827    // Get the relative path within the package (after the package name components)
828    let relative_parts = &after_inner_nm[pkg_name_components..];
829    if relative_parts.is_empty() {
830        return None;
831    }
832
833    let relative_path: PathBuf = relative_parts.iter().collect();
834
835    // Try direct file lookup in workspace root
836    let direct = ws_root.join(&relative_path);
837    if let Some(&file_id) = path_to_id.get(direct.as_path()) {
838        return Some(file_id);
839    }
840
841    // Try source fallback (dist/ → src/ etc.) within the workspace
842    try_source_fallback(&direct, path_to_id)
843}
844
845/// Convert a `DynamicImportPattern` to a glob string for file matching.
846fn make_glob_from_pattern(pattern: &fallow_types::extract::DynamicImportPattern) -> String {
847    // If the prefix already contains glob characters (from import.meta.glob), use as-is
848    if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
849        return pattern.prefix.clone();
850    }
851    pattern.suffix.as_ref().map_or_else(
852        || format!("{}*", pattern.prefix),
853        |suffix| format!("{}*{}", pattern.prefix, suffix),
854    )
855}
856
857/// Check if a specifier is a bare specifier (npm package or Node.js imports map entry).
858fn is_bare_specifier(specifier: &str) -> bool {
859    !specifier.starts_with('.')
860        && !specifier.starts_with('/')
861        && !specifier.contains("://")
862        && !specifier.starts_with("data:")
863}
864
865/// Extract the npm package name from a specifier.
866/// `@scope/pkg/foo/bar` -> `@scope/pkg`
867/// `lodash/merge` -> `lodash`
868pub fn extract_package_name(specifier: &str) -> String {
869    if specifier.starts_with('@') {
870        let parts: Vec<&str> = specifier.splitn(3, '/').collect();
871        if parts.len() >= 2 {
872            format!("{}/{}", parts[0], parts[1])
873        } else {
874            specifier.to_string()
875        }
876    } else {
877        specifier.split('/').next().unwrap_or(specifier).to_string()
878    }
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884
885    #[test]
886    fn test_extract_package_name() {
887        assert_eq!(extract_package_name("react"), "react");
888        assert_eq!(extract_package_name("lodash/merge"), "lodash");
889        assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
890        assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
891    }
892
893    #[test]
894    fn test_is_bare_specifier() {
895        assert!(is_bare_specifier("react"));
896        assert!(is_bare_specifier("@scope/pkg"));
897        assert!(is_bare_specifier("#internal/module"));
898        assert!(!is_bare_specifier("./utils"));
899        assert!(!is_bare_specifier("../lib"));
900        assert!(!is_bare_specifier("/absolute"));
901    }
902
903    #[test]
904    fn test_extract_package_name_from_node_modules_path_regular() {
905        let path = PathBuf::from("/project/node_modules/react/index.js");
906        assert_eq!(
907            extract_package_name_from_node_modules_path(&path),
908            Some("react".to_string())
909        );
910    }
911
912    #[test]
913    fn test_extract_package_name_from_node_modules_path_scoped() {
914        let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
915        assert_eq!(
916            extract_package_name_from_node_modules_path(&path),
917            Some("@babel/core".to_string())
918        );
919    }
920
921    #[test]
922    fn test_extract_package_name_from_node_modules_path_nested() {
923        // Nested node_modules: should use the last (innermost) one
924        let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
925        assert_eq!(
926            extract_package_name_from_node_modules_path(&path),
927            Some("pkg-b".to_string())
928        );
929    }
930
931    #[test]
932    fn test_extract_package_name_from_node_modules_path_deep_subpath() {
933        let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
934        assert_eq!(
935            extract_package_name_from_node_modules_path(&path),
936            Some("react-dom".to_string())
937        );
938    }
939
940    #[test]
941    fn test_extract_package_name_from_node_modules_path_no_node_modules() {
942        let path = PathBuf::from("/project/src/components/Button.tsx");
943        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
944    }
945
946    #[test]
947    fn test_extract_package_name_from_node_modules_path_just_node_modules() {
948        let path = PathBuf::from("/project/node_modules");
949        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
950    }
951
952    #[test]
953    fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
954        // Edge case: path ends at scope without package name
955        let path = PathBuf::from("/project/node_modules/@scope");
956        assert_eq!(
957            extract_package_name_from_node_modules_path(&path),
958            Some("@scope".to_string())
959        );
960    }
961
962    #[test]
963    fn test_resolve_specifier_node_modules_returns_npm_package() {
964        // When oxc_resolver resolves to a node_modules path that is NOT in path_to_id,
965        // it should return NpmPackage instead of ExternalFile.
966        // We can't easily test resolve_specifier directly without a real resolver,
967        // but the extract_package_name_from_node_modules_path function covers the
968        // core logic that was missing.
969        let path =
970            PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
971        assert_eq!(
972            extract_package_name_from_node_modules_path(&path),
973            Some("styled-components".to_string())
974        );
975
976        let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
977        assert_eq!(
978            extract_package_name_from_node_modules_path(&path),
979            Some("next".to_string())
980        );
981    }
982
983    #[test]
984    fn test_try_source_fallback_dist_to_src() {
985        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
986        let mut path_to_id = FxHashMap::default();
987        path_to_id.insert(src_path.as_path(), FileId(0));
988
989        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
990        assert_eq!(
991            try_source_fallback(&dist_path, &path_to_id),
992            Some(FileId(0)),
993            "dist/utils.js should fall back to src/utils.ts"
994        );
995    }
996
997    #[test]
998    fn test_try_source_fallback_build_to_src() {
999        let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1000        let mut path_to_id = FxHashMap::default();
1001        path_to_id.insert(src_path.as_path(), FileId(1));
1002
1003        let build_path = PathBuf::from("/project/packages/core/build/index.js");
1004        assert_eq!(
1005            try_source_fallback(&build_path, &path_to_id),
1006            Some(FileId(1)),
1007            "build/index.js should fall back to src/index.tsx"
1008        );
1009    }
1010
1011    #[test]
1012    fn test_try_source_fallback_no_match() {
1013        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1014
1015        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1016        assert_eq!(
1017            try_source_fallback(&dist_path, &path_to_id),
1018            None,
1019            "should return None when no source file exists"
1020        );
1021    }
1022
1023    #[test]
1024    fn test_try_source_fallback_non_output_dir() {
1025        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1026        let mut path_to_id = FxHashMap::default();
1027        path_to_id.insert(src_path.as_path(), FileId(0));
1028
1029        // A path that's not in an output directory should not trigger fallback
1030        let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1031        assert_eq!(
1032            try_source_fallback(&normal_path, &path_to_id),
1033            None,
1034            "non-output directory path should not trigger fallback"
1035        );
1036    }
1037
1038    #[test]
1039    fn test_try_source_fallback_nested_path() {
1040        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1041        let mut path_to_id = FxHashMap::default();
1042        path_to_id.insert(src_path.as_path(), FileId(2));
1043
1044        let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1045        assert_eq!(
1046            try_source_fallback(&dist_path, &path_to_id),
1047            Some(FileId(2)),
1048            "nested dist path should fall back to nested src path"
1049        );
1050    }
1051
1052    #[test]
1053    fn test_try_source_fallback_nested_dist_esm() {
1054        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1055        let mut path_to_id = FxHashMap::default();
1056        path_to_id.insert(src_path.as_path(), FileId(0));
1057
1058        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1059        assert_eq!(
1060            try_source_fallback(&dist_path, &path_to_id),
1061            Some(FileId(0)),
1062            "dist/esm/utils.mjs should fall back to src/utils.ts"
1063        );
1064    }
1065
1066    #[test]
1067    fn test_try_source_fallback_nested_build_cjs() {
1068        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1069        let mut path_to_id = FxHashMap::default();
1070        path_to_id.insert(src_path.as_path(), FileId(1));
1071
1072        let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1073        assert_eq!(
1074            try_source_fallback(&build_path, &path_to_id),
1075            Some(FileId(1)),
1076            "build/cjs/index.cjs should fall back to src/index.ts"
1077        );
1078    }
1079
1080    #[test]
1081    fn test_try_source_fallback_nested_dist_esm_deep_path() {
1082        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1083        let mut path_to_id = FxHashMap::default();
1084        path_to_id.insert(src_path.as_path(), FileId(2));
1085
1086        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1087        assert_eq!(
1088            try_source_fallback(&dist_path, &path_to_id),
1089            Some(FileId(2)),
1090            "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1091        );
1092    }
1093
1094    #[test]
1095    fn test_try_source_fallback_triple_nested_output_dirs() {
1096        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1097        let mut path_to_id = FxHashMap::default();
1098        path_to_id.insert(src_path.as_path(), FileId(0));
1099
1100        let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1101        assert_eq!(
1102            try_source_fallback(&dist_path, &path_to_id),
1103            Some(FileId(0)),
1104            "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1105        );
1106    }
1107
1108    #[test]
1109    fn test_try_source_fallback_parent_dir_named_build() {
1110        let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1111        let mut path_to_id = FxHashMap::default();
1112        path_to_id.insert(src_path.as_path(), FileId(0));
1113
1114        let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1115        assert_eq!(
1116            try_source_fallback(&dist_path, &path_to_id),
1117            Some(FileId(0)),
1118            "should resolve dist/ within project, not match parent 'build' dir"
1119        );
1120    }
1121
1122    #[test]
1123    fn test_pnpm_store_path_extract_package_name() {
1124        // pnpm virtual store paths should correctly extract package name
1125        let path =
1126            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1127        assert_eq!(
1128            extract_package_name_from_node_modules_path(&path),
1129            Some("react".to_string())
1130        );
1131    }
1132
1133    #[test]
1134    fn test_pnpm_store_path_scoped_package() {
1135        let path = PathBuf::from(
1136            "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
1137        );
1138        assert_eq!(
1139            extract_package_name_from_node_modules_path(&path),
1140            Some("@babel/core".to_string())
1141        );
1142    }
1143
1144    #[test]
1145    fn test_pnpm_store_path_with_peer_deps() {
1146        let path = PathBuf::from(
1147            "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
1148        );
1149        assert_eq!(
1150            extract_package_name_from_node_modules_path(&path),
1151            Some("webpack".to_string())
1152        );
1153    }
1154
1155    #[test]
1156    fn test_try_pnpm_workspace_fallback_dist_to_src() {
1157        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1158        let mut path_to_id = FxHashMap::default();
1159        path_to_id.insert(src_path.as_path(), FileId(0));
1160
1161        let mut workspace_roots = FxHashMap::default();
1162        let ws_root = PathBuf::from("/project/packages/ui");
1163        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1164
1165        // pnpm virtual store path with dist/ output
1166        let pnpm_path = PathBuf::from(
1167            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
1168        );
1169        assert_eq!(
1170            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1171            Some(FileId(0)),
1172            ".pnpm workspace path should fall back to src/utils.ts"
1173        );
1174    }
1175
1176    #[test]
1177    fn test_try_pnpm_workspace_fallback_direct_source() {
1178        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1179        let mut path_to_id = FxHashMap::default();
1180        path_to_id.insert(src_path.as_path(), FileId(1));
1181
1182        let mut workspace_roots = FxHashMap::default();
1183        let ws_root = PathBuf::from("/project/packages/core");
1184        workspace_roots.insert("@myorg/core", ws_root.as_path());
1185
1186        // pnpm path pointing directly to src/
1187        let pnpm_path = PathBuf::from(
1188            "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
1189        );
1190        assert_eq!(
1191            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1192            Some(FileId(1)),
1193            ".pnpm workspace path with src/ should resolve directly"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_try_pnpm_workspace_fallback_non_workspace_package() {
1199        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1200
1201        let mut workspace_roots = FxHashMap::default();
1202        let ws_root = PathBuf::from("/project/packages/ui");
1203        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1204
1205        // External package (not a workspace) — should return None
1206        let pnpm_path =
1207            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
1208        assert_eq!(
1209            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1210            None,
1211            "non-workspace package in .pnpm should return None"
1212        );
1213    }
1214
1215    #[test]
1216    fn test_try_pnpm_workspace_fallback_unscoped_package() {
1217        let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
1218        let mut path_to_id = FxHashMap::default();
1219        path_to_id.insert(src_path.as_path(), FileId(2));
1220
1221        let mut workspace_roots = FxHashMap::default();
1222        let ws_root = PathBuf::from("/project/packages/utils");
1223        workspace_roots.insert("my-utils", ws_root.as_path());
1224
1225        // Unscoped workspace package in pnpm store
1226        let pnpm_path = PathBuf::from(
1227            "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
1228        );
1229        assert_eq!(
1230            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1231            Some(FileId(2)),
1232            "unscoped workspace package in .pnpm should resolve"
1233        );
1234    }
1235
1236    #[test]
1237    fn test_try_pnpm_workspace_fallback_nested_path() {
1238        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1239        let mut path_to_id = FxHashMap::default();
1240        path_to_id.insert(src_path.as_path(), FileId(3));
1241
1242        let mut workspace_roots = FxHashMap::default();
1243        let ws_root = PathBuf::from("/project/packages/ui");
1244        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1245
1246        // Nested path within the package
1247        let pnpm_path = PathBuf::from(
1248            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
1249        );
1250        assert_eq!(
1251            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1252            Some(FileId(3)),
1253            "nested .pnpm workspace path should resolve through source fallback"
1254        );
1255    }
1256
1257    #[test]
1258    fn test_try_pnpm_workspace_fallback_no_pnpm() {
1259        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1260        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1261
1262        // Regular path without .pnpm — should return None immediately
1263        let regular_path = PathBuf::from("/project/node_modules/react/index.js");
1264        assert_eq!(
1265            try_pnpm_workspace_fallback(&regular_path, &path_to_id, &workspace_roots),
1266            None,
1267        );
1268    }
1269
1270    #[test]
1271    fn test_try_pnpm_workspace_fallback_with_peer_deps() {
1272        let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
1273        let mut path_to_id = FxHashMap::default();
1274        path_to_id.insert(src_path.as_path(), FileId(4));
1275
1276        let mut workspace_roots = FxHashMap::default();
1277        let ws_root = PathBuf::from("/project/packages/ui");
1278        workspace_roots.insert("@myorg/ui", ws_root.as_path());
1279
1280        // pnpm path with peer dependency suffix
1281        let pnpm_path = PathBuf::from(
1282            "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
1283        );
1284        assert_eq!(
1285            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
1286            Some(FileId(4)),
1287            ".pnpm path with peer dep suffix should still resolve"
1288        );
1289    }
1290
1291    #[test]
1292    fn test_has_react_native_plugin_active() {
1293        let plugins = vec!["react-native".to_string(), "typescript".to_string()];
1294        assert!(has_react_native_plugin(&plugins));
1295    }
1296
1297    #[test]
1298    fn test_has_expo_plugin_active() {
1299        let plugins = vec!["expo".to_string(), "typescript".to_string()];
1300        assert!(has_react_native_plugin(&plugins));
1301    }
1302
1303    #[test]
1304    fn test_has_react_native_plugin_inactive() {
1305        let plugins = vec!["nextjs".to_string(), "typescript".to_string()];
1306        assert!(!has_react_native_plugin(&plugins));
1307    }
1308
1309    #[test]
1310    fn test_rn_platform_extensions_prepended() {
1311        let no_rn = build_extensions(&[]);
1312        let rn_plugins = vec!["react-native".to_string()];
1313        let with_rn = build_extensions(&rn_plugins);
1314
1315        // Without RN, the first extension should be .ts
1316        assert_eq!(no_rn[0], ".ts");
1317
1318        // With RN, platform extensions should come first
1319        assert_eq!(with_rn[0], ".web.ts");
1320        assert_eq!(with_rn[1], ".web.tsx");
1321        assert_eq!(with_rn[2], ".web.js");
1322        assert_eq!(with_rn[3], ".web.jsx");
1323
1324        // Verify all 4 platforms (web, ios, android, native) x 4 exts = 16
1325        assert!(with_rn.len() > no_rn.len());
1326        assert_eq!(
1327            with_rn.len(),
1328            no_rn.len() + 16,
1329            "should add 16 platform extensions (4 platforms x 4 exts)"
1330        );
1331    }
1332
1333    #[test]
1334    fn test_rn_condition_names_prepended() {
1335        let no_rn = build_condition_names(&[]);
1336        let rn_plugins = vec!["react-native".to_string()];
1337        let with_rn = build_condition_names(&rn_plugins);
1338
1339        // Without RN, first condition should be "import"
1340        assert_eq!(no_rn[0], "import");
1341
1342        // With RN, "react-native" and "browser" should be prepended
1343        assert_eq!(with_rn[0], "react-native");
1344        assert_eq!(with_rn[1], "browser");
1345        assert_eq!(with_rn[2], "import");
1346    }
1347
1348    mod proptests {
1349        use super::*;
1350        use proptest::prelude::*;
1351
1352        proptest! {
1353            /// Any specifier starting with `.` or `/` must NOT be classified as a bare specifier.
1354            #[test]
1355            fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1356                let dot = format!(".{suffix}");
1357                let slash = format!("/{suffix}");
1358                prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
1359                prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
1360            }
1361
1362            /// Scoped packages (@scope/pkg) should extract exactly `@scope/pkg` — two segments.
1363            #[test]
1364            fn scoped_package_name_has_two_segments(
1365                scope in "[a-z][a-z0-9-]{0,20}",
1366                pkg in "[a-z][a-z0-9-]{0,20}",
1367                subpath in "(/[a-z0-9-]{1,20}){0,3}",
1368            ) {
1369                let specifier = format!("@{scope}/{pkg}{subpath}");
1370                let extracted = extract_package_name(&specifier);
1371                let expected = format!("@{scope}/{pkg}");
1372                prop_assert_eq!(extracted, expected);
1373            }
1374
1375            /// Unscoped packages should extract exactly the first path segment.
1376            #[test]
1377            fn unscoped_package_name_is_first_segment(
1378                pkg in "[a-z][a-z0-9-]{0,30}",
1379                subpath in "(/[a-z0-9-]{1,20}){0,3}",
1380            ) {
1381                let specifier = format!("{pkg}{subpath}");
1382                let extracted = extract_package_name(&specifier);
1383                prop_assert_eq!(extracted, pkg);
1384            }
1385
1386            /// is_bare_specifier and is_path_alias should never panic on arbitrary strings.
1387            #[test]
1388            fn bare_specifier_and_path_alias_no_panic(s in "[a-zA-Z0-9@#~/._-]{1,100}") {
1389                let _ = is_bare_specifier(&s);
1390                let _ = is_path_alias(&s);
1391            }
1392
1393            /// `@/` prefix should always be detected as a path alias.
1394            #[test]
1395            fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1396                let specifier = format!("@/{suffix}");
1397                prop_assert!(is_path_alias(&specifier));
1398            }
1399
1400            /// `~/` prefix should always be detected as a path alias.
1401            #[test]
1402            fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1403                let specifier = format!("~/{suffix}");
1404                prop_assert!(is_path_alias(&specifier));
1405            }
1406
1407            /// `#` prefix should always be detected as a path alias (Node.js imports map).
1408            #[test]
1409            fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
1410                let specifier = format!("#{suffix}");
1411                prop_assert!(is_path_alias(&specifier));
1412            }
1413
1414            /// Extracted package name from node_modules path should never be empty.
1415            #[test]
1416            fn node_modules_package_name_never_empty(
1417                pkg in "[a-z][a-z0-9-]{0,20}",
1418                file in "[a-z]{1,10}\\.(js|ts|mjs)",
1419            ) {
1420                let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
1421                if let Some(name) = extract_package_name_from_node_modules_path(&path) {
1422                    prop_assert!(!name.is_empty());
1423                }
1424            }
1425        }
1426    }
1427}