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