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