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::Mutex;
5
6use oxc_resolver::Resolver;
7use rustc_hash::{FxHashMap, FxHashSet};
8use serde_json::Value;
9
10use fallow_types::discover::FileId;
11
12/// Result of resolving an import specifier.
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub enum ResolveResult {
15    /// Resolved to a file within the project.
16    InternalModule(FileId),
17    /// Resolved to a project file through a framework convention auto-import.
18    SyntheticAutoImport(FileId),
19    /// Resolved to a workspace or self package source file while preserving
20    /// dependency usage for package accounting.
21    InternalPackageModule {
22        /// Internal source file reached by the package map.
23        file_id: FileId,
24        /// Package name that was used in the import specifier.
25        package_name: String,
26    },
27    /// Resolved to a file outside the project (`node_modules`, `.json`, etc.).
28    ExternalFile(PathBuf),
29    /// Bare specifier — an npm package.
30    NpmPackage(String),
31    /// Could not resolve.
32    Unresolvable(String),
33}
34
35impl ResolveResult {
36    /// Return the target file for any project-internal result.
37    #[must_use]
38    pub const fn internal_file_id(&self) -> Option<FileId> {
39        match self {
40            Self::InternalModule(file_id)
41            | Self::SyntheticAutoImport(file_id)
42            | Self::InternalPackageModule { file_id, .. } => Some(*file_id),
43            Self::ExternalFile(_) | Self::NpmPackage(_) | Self::Unresolvable(_) => None,
44        }
45    }
46
47    /// Return whether this edge was synthesized from framework auto-import conventions.
48    #[must_use]
49    pub const fn is_synthetic_auto_import(&self) -> bool {
50        matches!(self, Self::SyntheticAutoImport(_))
51    }
52
53    /// Return the package name that should receive dependency usage credit.
54    #[must_use]
55    pub fn package_usage_name(&self) -> Option<&str> {
56        match self {
57            Self::InternalPackageModule { package_name, .. } | Self::NpmPackage(package_name) => {
58                Some(package_name)
59            }
60            Self::InternalModule(_)
61            | Self::SyntheticAutoImport(_)
62            | Self::ExternalFile(_)
63            | Self::Unresolvable(_) => None,
64        }
65    }
66}
67
68/// A resolved import with its target.
69#[derive(Debug, Clone)]
70pub struct ResolvedImport {
71    /// The original import information.
72    pub info: fallow_types::extract::ImportInfo,
73    /// Where the import resolved to.
74    pub target: ResolveResult,
75}
76
77/// A resolved re-export with its target.
78#[derive(Debug, Clone)]
79pub struct ResolvedReExport {
80    /// The original re-export information.
81    pub info: fallow_types::extract::ReExportInfo,
82    /// Where the re-export source resolved to.
83    pub target: ResolveResult,
84}
85
86/// Any source-bearing module edge that resolves one literal specifier.
87pub enum ResolvedSourceEdge<'a> {
88    /// Static or literal dynamic import edge.
89    Import(&'a ResolvedImport),
90    /// Re-export source edge.
91    ReExport(&'a ResolvedReExport),
92}
93
94impl<'a> ResolvedSourceEdge<'a> {
95    /// Return the original source specifier.
96    #[must_use]
97    pub fn source_specifier(&self) -> &'a str {
98        match self {
99            Self::Import(import) => &import.info.source,
100            Self::ReExport(re_export) => &re_export.info.source,
101        }
102    }
103
104    /// Return the resolved target.
105    #[must_use]
106    pub const fn target(&self) -> &'a ResolveResult {
107        match self {
108            Self::Import(import) => &import.target,
109            Self::ReExport(re_export) => &re_export.target,
110        }
111    }
112
113    /// Return whether this edge is type-only.
114    #[must_use]
115    pub const fn is_type_only(&self) -> bool {
116        match self {
117            Self::Import(import) => import.info.is_type_only,
118            Self::ReExport(re_export) => re_export.info.is_type_only,
119        }
120    }
121
122    /// Return the span of the full import or re-export declaration.
123    #[must_use]
124    pub const fn span(&self) -> oxc_span::Span {
125        match self {
126            Self::Import(import) => import.info.span,
127            Self::ReExport(re_export) => re_export.info.span,
128        }
129    }
130
131    /// Return the source literal span when the extractor has one.
132    #[must_use]
133    pub const fn source_span(&self) -> oxc_span::Span {
134        match self {
135            Self::Import(import) => import.info.source_span,
136            Self::ReExport(_) => oxc_span::Span::new(0, 0),
137        }
138    }
139}
140
141/// Fully resolved module with all imports mapped to targets.
142#[derive(Debug)]
143pub struct ResolvedModule {
144    /// Unique file identifier.
145    pub file_id: FileId,
146    /// Absolute path to the module file.
147    pub path: PathBuf,
148    /// All export declarations in this module.
149    pub exports: Vec<fallow_types::extract::ExportInfo>,
150    /// All re-exports with resolved targets.
151    pub re_exports: Vec<ResolvedReExport>,
152    /// All static imports with resolved targets.
153    pub resolved_imports: Vec<ResolvedImport>,
154    /// All dynamic imports with resolved targets.
155    pub resolved_dynamic_imports: Vec<ResolvedImport>,
156    /// Dynamic import patterns matched against discovered files.
157    pub resolved_dynamic_patterns: Vec<(fallow_types::extract::DynamicImportPattern, Vec<FileId>)>,
158    /// Static member accesses (e.g., `Status.Active`).
159    pub member_accesses: Vec<fallow_types::extract::MemberAccess>,
160    /// Typed semantic facts produced by extraction for cross-layer analysis.
161    pub semantic_facts: Box<[fallow_types::extract::SemanticFact]>,
162    /// Identifiers used as whole objects (Object.values, for..in, spread, etc.).
163    pub whole_object_uses: Box<[String]>,
164    /// Whether this module uses `CommonJS` exports.
165    pub has_cjs_exports: bool,
166    /// Whether this module declares at least one Angular `@Component({
167    /// templateUrl: ... })` decorator. Mirrors `ModuleInfo.has_angular_component_template_url`;
168    /// see that field for the contract this gate enforces.
169    pub has_angular_component_template_url: bool,
170    /// Local names of import bindings that are never referenced in this file.
171    pub unused_import_bindings: FxHashSet<String>,
172    /// Local import bindings referenced from type positions.
173    pub type_referenced_import_bindings: Vec<String>,
174    /// Local import bindings referenced from runtime/value positions.
175    pub value_referenced_import_bindings: Vec<String>,
176    /// Namespace-import aliases re-exported through an object literal.
177    /// See `fallow_types::extract::NamespaceObjectAlias` for the shape.
178    pub namespace_object_aliases: Vec<fallow_types::extract::NamespaceObjectAlias>,
179    /// Exported free-function factories that provably return one class instance.
180    /// See `fallow_types::extract::FactoryReturnExport` and issue #1441 (Part A).
181    pub exported_factory_returns: Box<[fallow_types::extract::FactoryReturnExport]>,
182    /// Object-literal factory-return shapes (`export function createUi() {
183    /// return { orders: factory.ordersPage } }`), threaded from `ModuleInfo` for
184    /// the analyze-layer member-crediting join. See issue #1858.
185    pub exported_factory_return_object_shapes:
186        Box<[fallow_types::extract::FactoryReturnObjectShapeExport]>,
187    /// Named-type property types declared by this module's top-level interfaces
188    /// and type-literal aliases. See `fallow_types::extract::TypeMemberTypeEntry`
189    /// and issue #1785.
190    pub type_member_types: Box<[fallow_types::extract::TypeMemberTypeEntry]>,
191}
192
193impl Default for ResolvedModule {
194    fn default() -> Self {
195        Self {
196            file_id: FileId(0),
197            path: PathBuf::new(),
198            exports: vec![],
199            re_exports: vec![],
200            resolved_imports: vec![],
201            resolved_dynamic_imports: vec![],
202            resolved_dynamic_patterns: vec![],
203            member_accesses: vec![],
204            semantic_facts: Box::default(),
205            whole_object_uses: Box::default(),
206            has_cjs_exports: false,
207            has_angular_component_template_url: false,
208            unused_import_bindings: FxHashSet::default(),
209            type_referenced_import_bindings: vec![],
210            value_referenced_import_bindings: vec![],
211            namespace_object_aliases: vec![],
212            exported_factory_returns: Box::default(),
213            exported_factory_return_object_shapes: Box::default(),
214            type_member_types: Box::default(),
215        }
216    }
217}
218
219impl ResolvedModule {
220    /// Iterate over all concrete resolved imports in source order buckets.
221    ///
222    /// Includes static `import`/`require` edges and literal dynamic `import()`
223    /// edges. Dynamic import patterns are intentionally excluded because they
224    /// resolve to sets of files rather than single import specifiers.
225    pub fn all_resolved_imports(&self) -> impl Iterator<Item = &ResolvedImport> {
226        self.resolved_imports
227            .iter()
228            .chain(self.resolved_dynamic_imports.iter())
229    }
230
231    /// Iterate over every literal source edge that has one resolved target.
232    ///
233    /// Includes static imports, literal dynamic imports, and re-export sources.
234    /// Dynamic import patterns are excluded because they resolve to sets of
235    /// files rather than single import specifiers.
236    pub fn all_resolved_source_edges(&self) -> impl Iterator<Item = ResolvedSourceEdge<'_>> {
237        self.resolved_imports
238            .iter()
239            .map(ResolvedSourceEdge::Import)
240            .chain(
241                self.resolved_dynamic_imports
242                    .iter()
243                    .map(ResolvedSourceEdge::Import),
244            )
245            .chain(self.re_exports.iter().map(ResolvedSourceEdge::ReExport))
246    }
247}
248
249/// Shared context for resolving import specifiers.
250///
251/// Groups the immutable lookup tables and caches that are shared across all
252/// `resolve_specifier` calls within a single `resolve_all_imports` invocation.
253pub(super) struct ResolveContext<'a> {
254    /// The oxc_resolver instance (configured once, shared across threads).
255    pub resolver: &'a Resolver,
256    /// CSS-only resolver with package.json `sass` and `style` conditions enabled.
257    /// Used only for stylesheet package subpaths so JS/TS imports do not
258    /// accidentally prefer CSS export branches.
259    pub style_resolver: &'a Resolver,
260    /// Ordered extension list used by the resolver.
261    pub extensions: &'a [String],
262    /// Canonical path → FileId lookup (raw paths when root is canonical).
263    pub path_to_id: &'a FxHashMap<&'a Path, FileId>,
264    /// Raw (non-canonical) path → FileId lookup.
265    pub raw_path_to_id: &'a FxHashMap<&'a Path, FileId>,
266    /// Workspace name → canonical root path.
267    pub workspace_roots: &'a FxHashMap<&'a str, &'a Path>,
268    /// Package manifests for the root package and workspace packages.
269    pub package_manifests: &'a [PackageManifestInfo],
270    /// Ordered package condition names matching the resolver configuration.
271    pub condition_names: &'a [String],
272    /// Plugin-provided path aliases (prefix, replacement).
273    pub path_aliases: &'a [(String, String)],
274    /// Absolute directories to search when resolving bare SCSS/Sass
275    /// `@import` / `@use` specifiers. Populated from Angular's
276    /// `stylePreprocessorOptions.includePaths` and equivalent settings.
277    pub scss_include_paths: &'a [PathBuf],
278    /// Static directory URL mappings from framework config.
279    /// Each tuple is `(absolute_source_dir, normalized_url_mount)`.
280    pub static_dir_mappings: &'a [(PathBuf, String)],
281    /// Project root directory.
282    pub root: &'a Path,
283    /// Lazy canonical path → FileId fallback for intra-project symlinks.
284    /// Only initialized on first miss when root is canonical. `None` when
285    /// path_to_id already uses canonical paths (root is not canonical).
286    pub canonical_fallback: Option<&'a CanonicalFallback<'a>>,
287    /// Dedup set for broken-tsconfig warnings. Emits one `tracing::warn!`
288    /// per unique error message instead of spamming the log with one
289    /// warning per affected file. Shared across all parallel resolver
290    /// threads via `Mutex`. Empty and unused when no tsconfig errors occur.
291    pub tsconfig_warned: &'a Mutex<FxHashSet<String>>,
292    /// Per-analysis cache for local tsconfig discovery and JSON parsing.
293    /// Import resolution calls these fallbacks for every unresolved or
294    /// tsconfig-poisoned specifier, so keeping it session-local avoids
295    /// repeated filesystem work without risking stale data across runs.
296    pub tsconfig_cache: &'a TsconfigCache,
297    /// Per-analysis cache of `dunce::canonicalize` results keyed by resolved
298    /// path. Every import resolving to a `node_modules` / output-dir / symlinked
299    /// target is realpath'd during classification, and the same package path is
300    /// re-canonicalized for every file that imports the package. The result is a
301    /// pure function of the path's on-disk state (constant within a run), so the
302    /// cache is session-local for watch-mode safety.
303    pub canonicalize_cache: &'a CanonicalizeCache,
304}
305
306/// Session-local cache of `dunce::canonicalize` results keyed by input path.
307#[derive(Default)]
308pub(super) struct CanonicalizeCache {
309    map: Mutex<FxHashMap<PathBuf, Option<PathBuf>>>,
310}
311
312impl CanonicalizeCache {
313    /// Return the cached `dunce::canonicalize(path)` outcome, computing it on
314    /// first miss. `None` (a path that fails to canonicalize) is cached too so a
315    /// repeated probe of the same missing path does not re-issue the syscall.
316    pub fn get(&self, path: &Path) -> Option<PathBuf> {
317        if let Ok(cache) = self.map.lock()
318            && let Some(value) = cache.get(path)
319        {
320            return value.clone();
321        }
322        let value = dunce::canonicalize(path).ok();
323        if let Ok(mut cache) = self.map.lock() {
324            cache.insert(path.to_path_buf(), value.clone());
325        }
326        value
327    }
328}
329
330/// Session-local cache for tsconfig helper lookups used during import resolution.
331#[derive(Default)]
332pub(super) struct TsconfigCache {
333    json: Mutex<FxHashMap<PathBuf, Option<Value>>>,
334    chains: Mutex<FxHashMap<PathBuf, Vec<PathBuf>>>,
335}
336
337impl TsconfigCache {
338    /// Return a cached parsed tsconfig JSON value, loading it on first miss.
339    pub fn json(&self, path: &Path, load: impl FnOnce(&Path) -> Option<Value>) -> Option<Value> {
340        if let Ok(cache) = self.json.lock()
341            && let Some(value) = cache.get(path)
342        {
343            return value.clone();
344        }
345
346        let value = load(path);
347        if let Ok(mut cache) = self.json.lock() {
348            cache.insert(path.to_path_buf(), value.clone());
349        }
350        value
351    }
352
353    /// Return the cached tsconfig chain for a source file, if one exists.
354    pub fn chain(&self, from_file: &Path) -> Option<Vec<PathBuf>> {
355        self.chains
356            .lock()
357            .ok()
358            .and_then(|cache| cache.get(from_file).cloned())
359    }
360
361    /// Store the computed tsconfig chain for a source file.
362    pub fn store_chain(&self, from_file: &Path, chain: Vec<PathBuf>) {
363        if let Ok(mut cache) = self.chains.lock() {
364            cache.insert(from_file.to_path_buf(), chain);
365        }
366    }
367}
368
369/// Package manifest data used by source fallbacks.
370#[derive(Debug, Clone)]
371pub(super) struct PackageManifestInfo {
372    /// Package root path as discovered from the workspace tree.
373    pub root: PathBuf,
374    /// Canonical package root path for node_modules symlink comparisons.
375    pub canonical_root: PathBuf,
376    /// Parsed package name.
377    pub name: Option<String>,
378    /// Parsed package.json fields.
379    pub package_json: fallow_config::PackageJson,
380}
381
382/// Thread-safe lazy canonical path index, built on first access.
383pub(super) struct CanonicalFallback<'a> {
384    files: &'a [fallow_types::discover::DiscoveredFile],
385    map: std::sync::OnceLock<FxHashMap<std::path::PathBuf, FileId>>,
386}
387
388impl<'a> CanonicalFallback<'a> {
389    pub const fn new(files: &'a [fallow_types::discover::DiscoveredFile]) -> Self {
390        Self {
391            files,
392            map: std::sync::OnceLock::new(),
393        }
394    }
395
396    /// Look up a canonical path, lazily building the index on first call.
397    pub fn get(&self, canonical: &Path) -> Option<FileId> {
398        let map = self.map.get_or_init(|| {
399            tracing::debug!(
400                "intra-project symlinks detected, building canonical path index ({} files)",
401                self.files.len()
402            );
403            self.files
404                .iter()
405                .filter_map(|f| {
406                    dunce::canonicalize(&f.path)
407                        .ok()
408                        .map(|canonical| (canonical, f.id))
409                })
410                .collect()
411        });
412        map.get(canonical).copied()
413    }
414}
415
416#[cfg(all(test, not(miri)))]
417mod tests {
418    use super::*;
419    use fallow_types::discover::DiscoveredFile;
420
421    #[test]
422    fn canonical_fallback_returns_none_for_empty_files() {
423        let files: Vec<DiscoveredFile> = vec![];
424        let fallback = CanonicalFallback::new(&files);
425        assert!(fallback.get(Path::new("/nonexistent")).is_none());
426    }
427
428    #[test]
429    fn canonical_fallback_finds_existing_file() {
430        let temp = std::env::temp_dir().join("fallow-test-canonical-fallback");
431        let _ = std::fs::create_dir_all(&temp);
432        let test_file = temp.join("test.ts");
433        std::fs::write(&test_file, "").unwrap();
434
435        let files = vec![DiscoveredFile {
436            id: FileId(42),
437            path: test_file.clone(),
438            size_bytes: 0,
439        }];
440        let fallback = CanonicalFallback::new(&files);
441
442        let canonical = dunce::canonicalize(&test_file).unwrap();
443        assert_eq!(fallback.get(&canonical), Some(FileId(42)));
444
445        assert_eq!(fallback.get(&canonical), Some(FileId(42)));
446
447        let _ = std::fs::remove_dir_all(&temp);
448    }
449
450    #[test]
451    fn canonical_fallback_returns_none_for_missing_path() {
452        let temp = std::env::temp_dir().join("fallow-test-canonical-miss");
453        let _ = std::fs::create_dir_all(&temp);
454        let test_file = temp.join("exists.ts");
455        std::fs::write(&test_file, "").unwrap();
456
457        let files = vec![DiscoveredFile {
458            id: FileId(1),
459            path: test_file,
460            size_bytes: 0,
461        }];
462        let fallback = CanonicalFallback::new(&files);
463        assert!(fallback.get(Path::new("/nonexistent/file.ts")).is_none());
464
465        let _ = std::fs::remove_dir_all(&temp);
466    }
467}
468
469/// Known output directory names that may appear in exports map targets.
470/// When an exports map points to `./dist/utils.js`, we try replacing these
471/// prefixes with `src/` (the conventional source directory) to find the tracked
472/// source file.
473pub const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
474
475/// Source extensions to try when mapping a built output file back to source.
476pub const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
477
478/// React Native platform extension prefixes.
479/// Metro resolves platform-specific files (e.g., `./foo` -> `./foo.web.tsx` on web).
480pub const RN_PLATFORM_PREFIXES: &[&str] = &[".web", ".ios", ".android", ".native"];