Skip to main content

fallow_graph/resolve/
types.rs

1//! Type definitions and constants for import resolution.
2
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5
6use dashmap::DashMap;
7use oxc_resolver::Resolver;
8use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
9use serde_json::Value;
10
11use fallow_types::discover::FileId;
12
13/// Result of resolving an import specifier.
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub enum ResolveResult {
16    /// Resolved to a file within the project.
17    InternalModule(FileId),
18    /// Resolved from a CommonJS `require()` to a file within the project.
19    CommonJsInternalModule(FileId),
20    /// Resolved to a project file through a framework convention auto-import.
21    SyntheticAutoImport(FileId),
22    /// Resolved to a workspace or self package source file while preserving
23    /// dependency usage for package accounting.
24    InternalPackageModule {
25        /// Internal source file reached by the package map.
26        file_id: FileId,
27        /// Package name that was used in the import specifier.
28        package_name: String,
29    },
30    /// Resolved from a CommonJS `require()` to workspace or self-package source.
31    CommonJsInternalPackageModule {
32        /// Internal source file reached by the package map.
33        file_id: FileId,
34        /// Package name used in the require specifier.
35        package_name: String,
36    },
37    /// Resolved to a file outside the project (`node_modules`, `.json`, etc.).
38    ExternalFile(PathBuf),
39    /// Bare specifier — an npm package.
40    NpmPackage(String),
41    /// Bare npm package referenced through CommonJS `require()`.
42    CommonJsNpmPackage(String),
43    /// Could not resolve.
44    Unresolvable(String),
45}
46
47impl ResolveResult {
48    /// Return the target file for any project-internal result.
49    #[must_use]
50    pub const fn internal_file_id(&self) -> Option<FileId> {
51        match self {
52            Self::InternalModule(file_id)
53            | Self::CommonJsInternalModule(file_id)
54            | Self::SyntheticAutoImport(file_id)
55            | Self::InternalPackageModule { file_id, .. }
56            | Self::CommonJsInternalPackageModule { file_id, .. } => Some(*file_id),
57            Self::ExternalFile(_)
58            | Self::NpmPackage(_)
59            | Self::CommonJsNpmPackage(_)
60            | Self::Unresolvable(_) => None,
61        }
62    }
63
64    /// Return whether this edge was synthesized from framework auto-import conventions.
65    #[must_use]
66    pub const fn is_synthetic_auto_import(&self) -> bool {
67        matches!(self, Self::SyntheticAutoImport(_))
68    }
69
70    /// Return whether this edge originated from CommonJS `require()`.
71    #[must_use]
72    pub const fn is_commonjs_require(&self) -> bool {
73        matches!(
74            self,
75            Self::CommonJsInternalModule(_)
76                | Self::CommonJsInternalPackageModule { .. }
77                | Self::CommonJsNpmPackage(_)
78        )
79    }
80
81    /// Return whether this target is an unresolved bare package edge.
82    #[must_use]
83    pub const fn is_bare_package(&self) -> bool {
84        matches!(self, Self::NpmPackage(_) | Self::CommonJsNpmPackage(_))
85    }
86
87    /// Retain CommonJS provenance on targets that can participate in upgrades.
88    #[must_use]
89    pub fn into_commonjs_require(self) -> Self {
90        match self {
91            Self::InternalModule(file_id) => Self::CommonJsInternalModule(file_id),
92            Self::InternalPackageModule {
93                file_id,
94                package_name,
95            } => Self::CommonJsInternalPackageModule {
96                file_id,
97                package_name,
98            },
99            Self::NpmPackage(package_name) => Self::CommonJsNpmPackage(package_name),
100            other => other,
101        }
102    }
103
104    /// Remove CommonJS provenance while preserving the resolved destination.
105    #[must_use]
106    pub fn into_es_module(self) -> Self {
107        match self {
108            Self::CommonJsInternalModule(file_id) => Self::InternalModule(file_id),
109            Self::CommonJsInternalPackageModule {
110                file_id,
111                package_name,
112            } => Self::InternalPackageModule {
113                file_id,
114                package_name,
115            },
116            Self::CommonJsNpmPackage(package_name) => Self::NpmPackage(package_name),
117            other => other,
118        }
119    }
120
121    /// Return the package name that should receive dependency usage credit.
122    #[must_use]
123    pub fn package_usage_name(&self) -> Option<&str> {
124        match self {
125            Self::InternalPackageModule { package_name, .. }
126            | Self::CommonJsInternalPackageModule { package_name, .. }
127            | Self::NpmPackage(package_name)
128            | Self::CommonJsNpmPackage(package_name) => Some(package_name),
129            Self::InternalModule(_)
130            | Self::CommonJsInternalModule(_)
131            | Self::SyntheticAutoImport(_)
132            | Self::ExternalFile(_)
133            | Self::Unresolvable(_) => None,
134        }
135    }
136}
137
138/// Resolver output for one extracted project.
139#[derive(Debug, Default)]
140pub struct ResolvedProject {
141    /// Modules with every ordinary import and re-export resolved.
142    pub modules: Vec<ResolvedModule>,
143    /// Proven test-time replacements whose targets resolve inside the project.
144    pub replaced_module_targets: Vec<ResolvedReplacedModuleTarget>,
145}
146
147/// One project-internal module replacement resolved from its declaring source file.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct ResolvedReplacedModuleTarget {
150    /// File that declares the replacement fact.
151    pub source_file: FileId,
152    /// Project file replaced for this source's test-root traversal.
153    pub target_file: FileId,
154}
155
156/// A resolved import with its target.
157#[derive(Debug, Clone)]
158pub struct ResolvedImport {
159    /// The original import information.
160    pub info: fallow_types::extract::ImportInfo,
161    /// Where the import resolved to.
162    pub target: ResolveResult,
163}
164
165/// A resolved re-export with its target.
166#[derive(Debug, Clone)]
167pub struct ResolvedReExport {
168    /// The original re-export information.
169    pub info: fallow_types::extract::ReExportInfo,
170    /// Where the re-export source resolved to.
171    pub target: ResolveResult,
172}
173
174/// Any source-bearing module edge that resolves one literal specifier.
175pub enum ResolvedSourceEdge<'a> {
176    /// Static or literal dynamic import edge.
177    Import(&'a ResolvedImport),
178    /// Re-export source edge.
179    ReExport(&'a ResolvedReExport),
180}
181
182impl<'a> ResolvedSourceEdge<'a> {
183    /// Return the original source specifier.
184    #[must_use]
185    pub fn source_specifier(&self) -> &'a str {
186        match self {
187            Self::Import(import) => &import.info.source,
188            Self::ReExport(re_export) => &re_export.info.source,
189        }
190    }
191
192    /// Return the resolved target.
193    #[must_use]
194    pub const fn target(&self) -> &'a ResolveResult {
195        match self {
196            Self::Import(import) => &import.target,
197            Self::ReExport(re_export) => &re_export.target,
198        }
199    }
200
201    /// Return whether this edge is type-only.
202    #[must_use]
203    pub const fn is_type_only(&self) -> bool {
204        match self {
205            Self::Import(import) => import.info.is_type_only,
206            Self::ReExport(re_export) => re_export.info.is_type_only,
207        }
208    }
209
210    /// Return the span of the full import or re-export declaration.
211    #[must_use]
212    pub const fn span(&self) -> oxc_span::Span {
213        match self {
214            Self::Import(import) => import.info.span,
215            Self::ReExport(re_export) => re_export.info.span,
216        }
217    }
218
219    /// Return the source literal span when the extractor has one.
220    #[must_use]
221    pub const fn source_span(&self) -> oxc_span::Span {
222        match self {
223            Self::Import(import) => import.info.source_span,
224            Self::ReExport(re_export) => re_export.info.source_span,
225        }
226    }
227
228    /// Return the span of the enclosing statement.
229    ///
230    /// Imports do not record a statement span, so their per-binding
231    /// declaration span is the closest available statement anchor.
232    #[must_use]
233    pub const fn statement_span(&self) -> oxc_span::Span {
234        match self {
235            Self::Import(import) => import.info.span,
236            Self::ReExport(re_export) => re_export.info.statement_span,
237        }
238    }
239}
240
241/// Fully resolved module with all imports mapped to targets.
242///
243/// The `Arc<[T]>` fields are refcounted views of the same allocations owned by
244/// the source [`fallow_types::extract::ModuleInfo`], not copies; see the note
245/// on that struct.
246#[derive(Debug)]
247pub struct ResolvedModule {
248    /// Unique file identifier.
249    pub file_id: FileId,
250    /// Absolute path to the module file.
251    pub path: PathBuf,
252    /// All export declarations in this module.
253    pub exports: Arc<[fallow_types::extract::ExportInfo]>,
254    /// All re-exports with resolved targets.
255    pub re_exports: Vec<ResolvedReExport>,
256    /// All static imports with resolved targets.
257    pub resolved_imports: Vec<ResolvedImport>,
258    /// All dynamic imports with resolved targets.
259    pub resolved_dynamic_imports: Vec<ResolvedImport>,
260    /// Dynamic import patterns matched against discovered files.
261    pub resolved_dynamic_patterns: Vec<(fallow_types::extract::DynamicImportPattern, Vec<FileId>)>,
262    /// Static member accesses (e.g., `Status.Active`).
263    pub member_accesses: Arc<[fallow_types::extract::MemberAccess]>,
264    /// Typed semantic facts produced by extraction for cross-layer analysis.
265    pub semantic_facts: Arc<[fallow_types::extract::SemanticFact]>,
266    /// Identifiers used as whole objects (Object.values, for..in, spread, etc.).
267    pub whole_object_uses: Arc<[String]>,
268    /// Whether this module uses `CommonJS` exports.
269    pub has_cjs_exports: bool,
270    /// Whether this module declares at least one Angular `@Component({
271    /// templateUrl: ... })` decorator. Mirrors `ModuleInfo.has_angular_component_template_url`;
272    /// see that field for the contract this gate enforces.
273    pub has_angular_component_template_url: bool,
274    /// Local names of import bindings that are never referenced in this file.
275    pub unused_import_bindings: FxHashSet<String>,
276    /// Local import bindings referenced from type positions.
277    pub type_referenced_import_bindings: Vec<String>,
278    /// Local import bindings referenced from runtime/value positions.
279    pub value_referenced_import_bindings: Vec<String>,
280    /// Namespace-import aliases re-exported through an object literal.
281    /// See `fallow_types::extract::NamespaceObjectAlias` for the shape.
282    pub namespace_object_aliases: Vec<fallow_types::extract::NamespaceObjectAlias>,
283    /// Exported free-function factories that provably return one class instance.
284    /// See `fallow_types::extract::FactoryReturnExport` and issue #1441 (Part A).
285    pub exported_factory_returns: Arc<[fallow_types::extract::FactoryReturnExport]>,
286    /// Object-literal factory-return shapes (`export function createUi() {
287    /// return { orders: factory.ordersPage } }`), threaded from `ModuleInfo` for
288    /// the analyze-layer member-crediting join. See issue #1858.
289    pub exported_factory_return_object_shapes:
290        Arc<[fallow_types::extract::FactoryReturnObjectShapeExport]>,
291    /// Named-type property types declared by this module's top-level interfaces
292    /// and type-literal aliases. See `fallow_types::extract::TypeMemberTypeEntry`
293    /// and issue #1785.
294    pub type_member_types: Arc<[fallow_types::extract::TypeMemberTypeEntry]>,
295}
296
297impl Default for ResolvedModule {
298    fn default() -> Self {
299        Self {
300            file_id: FileId(0),
301            path: PathBuf::new(),
302            exports: Arc::default(),
303            re_exports: vec![],
304            resolved_imports: vec![],
305            resolved_dynamic_imports: vec![],
306            resolved_dynamic_patterns: vec![],
307            member_accesses: Arc::default(),
308            semantic_facts: Arc::default(),
309            whole_object_uses: Arc::default(),
310            has_cjs_exports: false,
311            has_angular_component_template_url: false,
312            unused_import_bindings: FxHashSet::default(),
313            type_referenced_import_bindings: vec![],
314            value_referenced_import_bindings: vec![],
315            namespace_object_aliases: vec![],
316            exported_factory_returns: Arc::default(),
317            exported_factory_return_object_shapes: Arc::default(),
318            type_member_types: Arc::default(),
319        }
320    }
321}
322
323impl ResolvedModule {
324    /// Iterate over all concrete resolved imports in source order buckets.
325    ///
326    /// Includes static `import`/`require` edges and literal dynamic `import()`
327    /// edges. Dynamic import patterns are intentionally excluded because they
328    /// resolve to sets of files rather than single import specifiers.
329    pub fn all_resolved_imports(&self) -> impl Iterator<Item = &ResolvedImport> {
330        self.resolved_imports
331            .iter()
332            .chain(self.resolved_dynamic_imports.iter())
333    }
334
335    /// Iterate over every literal source edge that has one resolved target.
336    ///
337    /// Includes static imports, literal dynamic imports, and re-export sources.
338    /// Dynamic import patterns are excluded because they resolve to sets of
339    /// files rather than single import specifiers.
340    pub fn all_resolved_source_edges(&self) -> impl Iterator<Item = ResolvedSourceEdge<'_>> {
341        self.resolved_imports
342            .iter()
343            .map(ResolvedSourceEdge::Import)
344            .chain(
345                self.resolved_dynamic_imports
346                    .iter()
347                    .map(ResolvedSourceEdge::Import),
348            )
349            .chain(self.re_exports.iter().map(ResolvedSourceEdge::ReExport))
350    }
351}
352
353/// Shared context for resolving import specifiers.
354///
355/// Groups the immutable lookup tables and caches that are shared across all
356/// `resolve_specifier` calls within a single `resolve_all_imports` invocation.
357pub(super) struct ResolveContext<'a> {
358    /// The oxc_resolver instance (configured once, shared across threads).
359    pub resolver: &'a Resolver,
360    /// CSS-only resolver with package.json `sass` and `style` conditions enabled.
361    /// Used only for stylesheet package subpaths so JS/TS imports do not
362    /// accidentally prefer CSS export branches.
363    pub style_resolver: &'a Resolver,
364    /// Ordered extension list used by the resolver.
365    pub extensions: &'a [String],
366    /// Canonical path → FileId lookup (raw paths when root is canonical).
367    pub path_to_id: &'a FxHashMap<&'a Path, FileId>,
368    /// Raw (non-canonical) path → FileId lookup.
369    pub raw_path_to_id: &'a FxHashMap<&'a Path, FileId>,
370    /// Workspace name → canonical root path.
371    pub workspace_roots: &'a FxHashMap<&'a str, &'a Path>,
372    /// Package manifests for the root package and workspace packages.
373    pub package_manifests: &'a [PackageManifestInfo],
374    /// Whether any package scope declared a Deno import map; false skips map
375    /// lookup per import.
376    pub has_deno_import_maps: bool,
377    /// Ordered package condition names matching the resolver configuration.
378    pub condition_names: &'a [String],
379    /// Plugin-provided path aliases (prefix, replacement).
380    pub path_aliases: &'a [(String, String)],
381    /// Absolute directories to search when resolving bare SCSS/Sass
382    /// `@import` / `@use` specifiers. Populated from Angular's
383    /// `stylePreprocessorOptions.includePaths` and equivalent settings.
384    pub scss_include_paths: &'a [PathBuf],
385    /// Static directory URL mappings from framework config.
386    /// Each tuple is `(absolute_source_dir, normalized_url_mount)`.
387    pub static_dir_mappings: &'a [(PathBuf, String)],
388    /// Mounts a framework serves for the whole project, reachable from any HTML
389    /// document, unlike `static_dir_mappings`, which stays scoped to the tool
390    /// whose config declared it.
391    pub framework_static_dir_mappings: &'a [(PathBuf, String)],
392    /// Project root directory.
393    pub root: &'a Path,
394    /// Lazy canonical path → FileId fallback for intra-project symlinks.
395    /// Only initialized on first miss when root is canonical. `None` when
396    /// path_to_id already uses canonical paths (root is not canonical).
397    pub canonical_fallback: Option<&'a CanonicalFallback<'a>>,
398    /// Dedup set for broken-tsconfig warnings. Emits one `tracing::warn!`
399    /// per unique error message instead of spamming the log with one
400    /// warning per affected file. Shared across all parallel resolver
401    /// threads via `Mutex`. Empty and unused when no tsconfig errors occur.
402    pub tsconfig_warned: &'a Mutex<FxHashSet<String>>,
403    /// Per-analysis cache for local tsconfig discovery and JSON parsing.
404    /// Import resolution calls these fallbacks for every unresolved or
405    /// tsconfig-poisoned specifier, so keeping it session-local avoids
406    /// repeated filesystem work without risking stale data across runs.
407    pub tsconfig_cache: &'a TsconfigCache,
408    /// Per-analysis cache of `dunce::canonicalize` results keyed by resolved
409    /// path. Every import resolving to a `node_modules` / output-dir / symlinked
410    /// target is realpath'd during classification, and the same package path is
411    /// re-canonicalized for every file that imports the package. The result is a
412    /// pure function of the path's on-disk state (constant within a run), so the
413    /// cache is session-local for watch-mode safety.
414    pub canonicalize_cache: &'a CanonicalizeCache,
415}
416
417/// Session-local cache of `dunce::canonicalize` results keyed by input path.
418#[derive(Default)]
419pub(super) struct CanonicalizeCache {
420    map: DashMap<PathBuf, Option<PathBuf>, FxBuildHasher>,
421}
422
423impl CanonicalizeCache {
424    /// Return the cached `dunce::canonicalize(path)` outcome, computing it on
425    /// first miss. `None` (a path that fails to canonicalize) is cached too so a
426    /// repeated probe of the same missing path does not re-issue the syscall.
427    pub fn get(&self, path: &Path) -> Option<PathBuf> {
428        if let Some(value) = self.map.get(path) {
429            return value.clone();
430        }
431        let value = dunce::canonicalize(path).ok();
432        self.map.insert(path.to_path_buf(), value.clone());
433        value
434    }
435}
436
437/// Session-local cache for tsconfig helper lookups used during import resolution.
438#[derive(Default)]
439pub(super) struct TsconfigCache {
440    json: DashMap<PathBuf, Option<Arc<Value>>, FxBuildHasher>,
441    chains: DashMap<PathBuf, Arc<[PathBuf]>, FxBuildHasher>,
442}
443
444impl TsconfigCache {
445    /// Return a cached parsed tsconfig JSON value, loading it on first miss.
446    ///
447    /// Handing back an [`Arc`] rather than a clone matters: a tsconfig chain is
448    /// walked several times per import specifier, and deep-copying every parsed
449    /// document on each hop dominated resolution on large project-reference
450    /// graphs.
451    pub fn json(
452        &self,
453        path: &Path,
454        load: impl FnOnce(&Path) -> Option<Value>,
455    ) -> Option<Arc<Value>> {
456        if let Some(value) = self.json.get(path) {
457            return value.clone();
458        }
459
460        let value = load(path).map(Arc::new);
461        self.json.insert(path.to_path_buf(), value.clone());
462        value
463    }
464
465    /// Return the cached tsconfig chain for a source file, if one exists.
466    pub fn chain(&self, from_file: &Path) -> Option<Arc<[PathBuf]>> {
467        self.chains.get(from_file).map(|entry| Arc::clone(&entry))
468    }
469
470    /// Store the computed tsconfig chain for a source file.
471    pub fn store_chain(&self, from_file: &Path, chain: Arc<[PathBuf]>) {
472        self.chains.insert(from_file.to_path_buf(), chain);
473    }
474}
475
476/// Package manifest data used by source fallbacks.
477#[derive(Debug, Clone)]
478pub(super) struct PackageManifestInfo {
479    /// Package root path as discovered from the workspace tree.
480    pub root: PathBuf,
481    /// Canonical package root path for node_modules symlink comparisons.
482    pub canonical_root: PathBuf,
483    /// Parsed package name.
484    pub name: Option<String>,
485    /// Parsed package.json fields.
486    pub package_json: fallow_config::PackageJson,
487    /// Effective Deno import map for this package scope.
488    pub deno_import_map: Vec<DenoImportMapEntry>,
489}
490
491/// One effective Deno import-map entry with the directory that declared it.
492#[derive(Debug, Clone)]
493pub(super) struct DenoImportMapEntry {
494    pub key: String,
495    pub target: String,
496    pub declaring_dir: PathBuf,
497}
498
499/// Thread-safe lazy canonical path index, built on first access.
500pub(super) struct CanonicalFallback<'a> {
501    files: &'a [fallow_types::discover::DiscoveredFile],
502    map: std::sync::OnceLock<FxHashMap<std::path::PathBuf, FileId>>,
503}
504
505impl<'a> CanonicalFallback<'a> {
506    pub const fn new(files: &'a [fallow_types::discover::DiscoveredFile]) -> Self {
507        Self {
508            files,
509            map: std::sync::OnceLock::new(),
510        }
511    }
512
513    /// Look up a canonical path, lazily building the index on first call.
514    pub fn get(&self, canonical: &Path) -> Option<FileId> {
515        let map = self.map.get_or_init(|| {
516            tracing::debug!(
517                "intra-project symlinks detected, building canonical path index ({} files)",
518                self.files.len()
519            );
520            self.files
521                .iter()
522                .filter_map(|f| {
523                    dunce::canonicalize(&f.path)
524                        .ok()
525                        .map(|canonical| (canonical, f.id))
526                })
527                .collect()
528        });
529        map.get(canonical).copied()
530    }
531}
532
533#[cfg(all(test, not(miri)))]
534mod tests {
535    use super::*;
536    use fallow_types::discover::DiscoveredFile;
537
538    #[test]
539    fn canonical_fallback_returns_none_for_empty_files() {
540        let files: Vec<DiscoveredFile> = vec![];
541        let fallback = CanonicalFallback::new(&files);
542        assert!(fallback.get(Path::new("/nonexistent")).is_none());
543    }
544
545    #[test]
546    fn canonical_fallback_finds_existing_file() {
547        let temp = std::env::temp_dir().join("fallow-test-canonical-fallback");
548        let _ = std::fs::create_dir_all(&temp);
549        let test_file = temp.join("test.ts");
550        std::fs::write(&test_file, "").unwrap();
551
552        let files = vec![DiscoveredFile {
553            id: FileId(42),
554            path: test_file.clone(),
555            size_bytes: 0,
556        }];
557        let fallback = CanonicalFallback::new(&files);
558
559        let canonical = dunce::canonicalize(&test_file).unwrap();
560        assert_eq!(fallback.get(&canonical), Some(FileId(42)));
561
562        assert_eq!(fallback.get(&canonical), Some(FileId(42)));
563
564        let _ = std::fs::remove_dir_all(&temp);
565    }
566
567    #[test]
568    fn canonical_fallback_returns_none_for_missing_path() {
569        let temp = std::env::temp_dir().join("fallow-test-canonical-miss");
570        let _ = std::fs::create_dir_all(&temp);
571        let test_file = temp.join("exists.ts");
572        std::fs::write(&test_file, "").unwrap();
573
574        let files = vec![DiscoveredFile {
575            id: FileId(1),
576            path: test_file,
577            size_bytes: 0,
578        }];
579        let fallback = CanonicalFallback::new(&files);
580        assert!(fallback.get(Path::new("/nonexistent/file.ts")).is_none());
581
582        let _ = std::fs::remove_dir_all(&temp);
583    }
584
585    #[test]
586    fn tsconfig_cache_loads_once_and_shares_the_parsed_document() {
587        let cache = TsconfigCache::default();
588        let path = Path::new("/project/tsconfig.json");
589        let loads = std::sync::atomic::AtomicUsize::new(0);
590        let load = |_: &Path| {
591            loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
592            Some(serde_json::json!({ "compilerOptions": {} }))
593        };
594
595        let first = cache.json(path, load).unwrap();
596        let second = cache.json(path, load).unwrap();
597
598        assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
599        assert!(
600            Arc::ptr_eq(&first, &second),
601            "repeat reads must share one allocation rather than deep-copy"
602        );
603    }
604
605    /// An unreadable tsconfig is cached as a miss so the read is not retried.
606    #[test]
607    fn tsconfig_cache_caches_a_failed_load() {
608        let cache = TsconfigCache::default();
609        let path = Path::new("/project/missing.json");
610        let loads = std::sync::atomic::AtomicUsize::new(0);
611        let load = |_: &Path| {
612            loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
613            None
614        };
615
616        assert!(cache.json(path, load).is_none());
617        assert!(cache.json(path, load).is_none());
618        assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
619    }
620
621    #[test]
622    fn tsconfig_cache_round_trips_a_chain() {
623        let cache = TsconfigCache::default();
624        let from_file = Path::new("/project/src/index.ts");
625        assert!(cache.chain(from_file).is_none());
626
627        let chain: Arc<[PathBuf]> = vec![PathBuf::from("/project/tsconfig.json")].into();
628        cache.store_chain(from_file, Arc::clone(&chain));
629
630        assert!(Arc::ptr_eq(&cache.chain(from_file).unwrap(), &chain));
631    }
632
633    /// Both caches are read concurrently by rayon workers during resolution.
634    #[test]
635    fn tsconfig_cache_is_consistent_under_concurrent_access() {
636        const THREADS: usize = 8;
637        const PATHS: usize = 32;
638
639        let cache = TsconfigCache::default();
640        std::thread::scope(|scope| {
641            for _ in 0..THREADS {
642                scope.spawn(|| {
643                    for index in 0..PATHS {
644                        let path = PathBuf::from(format!("/project/{index}/tsconfig.json"));
645                        let json = cache
646                            .json(&path, |_| Some(serde_json::json!({ "index": index })))
647                            .unwrap();
648                        assert_eq!(json["index"], index);
649                    }
650                });
651            }
652        });
653    }
654
655    #[test]
656    fn canonicalize_cache_returns_the_same_result_on_repeat_lookups() {
657        let temp = tempfile::tempdir().expect("create temp dir");
658        let file = temp.path().join("file.ts");
659        std::fs::write(&file, "").unwrap();
660
661        let cache = CanonicalizeCache::default();
662        let expected = dunce::canonicalize(&file).ok();
663        assert_eq!(cache.get(&file), expected);
664        assert_eq!(cache.get(&file), expected);
665        assert!(cache.get(&temp.path().join("missing.ts")).is_none());
666    }
667
668    #[test]
669    fn commonjs_provenance_wraps_internal_and_bare_package_targets() {
670        assert!(matches!(
671            ResolveResult::InternalModule(FileId(4)).into_commonjs_require(),
672            ResolveResult::CommonJsInternalModule(FileId(4))
673        ));
674        assert!(matches!(
675            ResolveResult::InternalPackageModule {
676                file_id: FileId(5),
677                package_name: "pkg".to_string(),
678            }
679            .into_commonjs_require(),
680            ResolveResult::CommonJsInternalPackageModule {
681                file_id: FileId(5),
682                package_name,
683            } if package_name == "pkg"
684        ));
685        assert!(matches!(
686            ResolveResult::NpmPackage("pkg".to_string()).into_commonjs_require(),
687            ResolveResult::CommonJsNpmPackage(package_name) if package_name == "pkg"
688        ));
689    }
690}
691
692/// Known output directory names that may appear in exports map targets.
693/// When an exports map points to `./dist/utils.js`, we try replacing these
694/// prefixes with `src/` (the conventional source directory) to find the tracked
695/// source file.
696pub const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
697
698/// Source extensions to try when mapping a built output file back to source.
699pub const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
700
701/// React Native platform extension prefixes.
702/// Metro resolves platform-specific files (e.g., `./foo` -> `./foo.web.tsx` on web).
703pub const RN_PLATFORM_PREFIXES: &[&str] = &[".web", ".ios", ".android", ".native"];