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};
8
9use fallow_types::discover::FileId;
10
11/// Result of resolving an import specifier.
12#[derive(Debug, Clone)]
13pub enum ResolveResult {
14    /// Resolved to a file within the project.
15    InternalModule(FileId),
16    /// Resolved to a workspace or self package source file while preserving
17    /// dependency usage for package accounting.
18    InternalPackageModule {
19        /// Internal source file reached by the package map.
20        file_id: FileId,
21        /// Package name that was used in the import specifier.
22        package_name: String,
23    },
24    /// Resolved to a file outside the project (`node_modules`, `.json`, etc.).
25    ExternalFile(PathBuf),
26    /// Bare specifier — an npm package.
27    NpmPackage(String),
28    /// Could not resolve.
29    Unresolvable(String),
30}
31
32impl ResolveResult {
33    /// Return the target file for any project-internal result.
34    #[must_use]
35    pub const fn internal_file_id(&self) -> Option<FileId> {
36        match self {
37            Self::InternalModule(file_id) | Self::InternalPackageModule { file_id, .. } => {
38                Some(*file_id)
39            }
40            Self::ExternalFile(_) | Self::NpmPackage(_) | Self::Unresolvable(_) => None,
41        }
42    }
43
44    /// Return the package name that should receive dependency usage credit.
45    #[must_use]
46    pub fn package_usage_name(&self) -> Option<&str> {
47        match self {
48            Self::InternalPackageModule { package_name, .. } | Self::NpmPackage(package_name) => {
49                Some(package_name)
50            }
51            Self::InternalModule(_) | Self::ExternalFile(_) | Self::Unresolvable(_) => None,
52        }
53    }
54}
55
56/// A resolved import with its target.
57#[derive(Debug, Clone)]
58pub struct ResolvedImport {
59    /// The original import information.
60    pub info: fallow_types::extract::ImportInfo,
61    /// Where the import resolved to.
62    pub target: ResolveResult,
63}
64
65/// A resolved re-export with its target.
66#[derive(Debug, Clone)]
67pub struct ResolvedReExport {
68    /// The original re-export information.
69    pub info: fallow_types::extract::ReExportInfo,
70    /// Where the re-export source resolved to.
71    pub target: ResolveResult,
72}
73
74/// Any source-bearing module edge that resolves one literal specifier.
75pub enum ResolvedSourceEdge<'a> {
76    /// Static or literal dynamic import edge.
77    Import(&'a ResolvedImport),
78    /// Re-export source edge.
79    ReExport(&'a ResolvedReExport),
80}
81
82impl<'a> ResolvedSourceEdge<'a> {
83    /// Return the original source specifier.
84    #[must_use]
85    pub fn source_specifier(&self) -> &'a str {
86        match self {
87            Self::Import(import) => &import.info.source,
88            Self::ReExport(re_export) => &re_export.info.source,
89        }
90    }
91
92    /// Return the resolved target.
93    #[must_use]
94    pub const fn target(&self) -> &'a ResolveResult {
95        match self {
96            Self::Import(import) => &import.target,
97            Self::ReExport(re_export) => &re_export.target,
98        }
99    }
100
101    /// Return whether this edge is type-only.
102    #[must_use]
103    pub const fn is_type_only(&self) -> bool {
104        match self {
105            Self::Import(import) => import.info.is_type_only,
106            Self::ReExport(re_export) => re_export.info.is_type_only,
107        }
108    }
109
110    /// Return the span of the full import or re-export declaration.
111    #[must_use]
112    pub const fn span(&self) -> oxc_span::Span {
113        match self {
114            Self::Import(import) => import.info.span,
115            Self::ReExport(re_export) => re_export.info.span,
116        }
117    }
118
119    /// Return the source literal span when the extractor has one.
120    #[must_use]
121    pub const fn source_span(&self) -> oxc_span::Span {
122        match self {
123            Self::Import(import) => import.info.source_span,
124            Self::ReExport(_) => oxc_span::Span::new(0, 0),
125        }
126    }
127}
128
129/// Fully resolved module with all imports mapped to targets.
130#[derive(Debug)]
131pub struct ResolvedModule {
132    /// Unique file identifier.
133    pub file_id: FileId,
134    /// Absolute path to the module file.
135    pub path: PathBuf,
136    /// All export declarations in this module.
137    pub exports: Vec<fallow_types::extract::ExportInfo>,
138    /// All re-exports with resolved targets.
139    pub re_exports: Vec<ResolvedReExport>,
140    /// All static imports with resolved targets.
141    pub resolved_imports: Vec<ResolvedImport>,
142    /// All dynamic imports with resolved targets.
143    pub resolved_dynamic_imports: Vec<ResolvedImport>,
144    /// Dynamic import patterns matched against discovered files.
145    pub resolved_dynamic_patterns: Vec<(fallow_types::extract::DynamicImportPattern, Vec<FileId>)>,
146    /// Static member accesses (e.g., `Status.Active`).
147    pub member_accesses: Vec<fallow_types::extract::MemberAccess>,
148    /// Identifiers used as whole objects (Object.values, for..in, spread, etc.).
149    pub whole_object_uses: Vec<String>,
150    /// Whether this module uses `CommonJS` exports.
151    pub has_cjs_exports: bool,
152    /// Whether this module declares at least one Angular `@Component({
153    /// templateUrl: ... })` decorator. Mirrors `ModuleInfo.has_angular_component_template_url`;
154    /// see that field for the contract this gate enforces.
155    pub has_angular_component_template_url: bool,
156    /// Local names of import bindings that are never referenced in this file.
157    pub unused_import_bindings: FxHashSet<String>,
158    /// Local import bindings referenced from type positions.
159    pub type_referenced_import_bindings: Vec<String>,
160    /// Local import bindings referenced from runtime/value positions.
161    pub value_referenced_import_bindings: Vec<String>,
162    /// Namespace-import aliases re-exported through an object literal.
163    /// See `fallow_types::extract::NamespaceObjectAlias` for the shape.
164    pub namespace_object_aliases: Vec<fallow_types::extract::NamespaceObjectAlias>,
165}
166
167impl Default for ResolvedModule {
168    fn default() -> Self {
169        Self {
170            file_id: FileId(0),
171            path: PathBuf::new(),
172            exports: vec![],
173            re_exports: vec![],
174            resolved_imports: vec![],
175            resolved_dynamic_imports: vec![],
176            resolved_dynamic_patterns: vec![],
177            member_accesses: vec![],
178            whole_object_uses: vec![],
179            has_cjs_exports: false,
180            has_angular_component_template_url: false,
181            unused_import_bindings: FxHashSet::default(),
182            type_referenced_import_bindings: vec![],
183            value_referenced_import_bindings: vec![],
184            namespace_object_aliases: vec![],
185        }
186    }
187}
188
189impl ResolvedModule {
190    /// Iterate over all concrete resolved imports in source order buckets.
191    ///
192    /// Includes static `import`/`require` edges and literal dynamic `import()`
193    /// edges. Dynamic import patterns are intentionally excluded because they
194    /// resolve to sets of files rather than single import specifiers.
195    pub fn all_resolved_imports(&self) -> impl Iterator<Item = &ResolvedImport> {
196        self.resolved_imports
197            .iter()
198            .chain(self.resolved_dynamic_imports.iter())
199    }
200
201    /// Iterate over every literal source edge that has one resolved target.
202    ///
203    /// Includes static imports, literal dynamic imports, and re-export sources.
204    /// Dynamic import patterns are excluded because they resolve to sets of
205    /// files rather than single import specifiers.
206    pub fn all_resolved_source_edges(&self) -> impl Iterator<Item = ResolvedSourceEdge<'_>> {
207        self.resolved_imports
208            .iter()
209            .map(ResolvedSourceEdge::Import)
210            .chain(
211                self.resolved_dynamic_imports
212                    .iter()
213                    .map(ResolvedSourceEdge::Import),
214            )
215            .chain(self.re_exports.iter().map(ResolvedSourceEdge::ReExport))
216    }
217}
218
219/// Shared context for resolving import specifiers.
220///
221/// Groups the immutable lookup tables and caches that are shared across all
222/// `resolve_specifier` calls within a single `resolve_all_imports` invocation.
223pub(super) struct ResolveContext<'a> {
224    /// The oxc_resolver instance (configured once, shared across threads).
225    pub resolver: &'a Resolver,
226    /// CSS-only resolver with the package.json `style` condition enabled.
227    /// Used only for stylesheet package subpaths so JS/TS imports do not
228    /// accidentally prefer CSS export branches.
229    pub style_resolver: &'a Resolver,
230    /// Ordered extension list used by the resolver.
231    pub extensions: &'a [String],
232    /// Canonical path → FileId lookup (raw paths when root is canonical).
233    pub path_to_id: &'a FxHashMap<&'a Path, FileId>,
234    /// Raw (non-canonical) path → FileId lookup.
235    pub raw_path_to_id: &'a FxHashMap<&'a Path, FileId>,
236    /// Workspace name → canonical root path.
237    pub workspace_roots: &'a FxHashMap<&'a str, &'a Path>,
238    /// Package manifests for the root package and workspace packages.
239    pub package_manifests: &'a [PackageManifestInfo],
240    /// Ordered package condition names matching the resolver configuration.
241    pub condition_names: &'a [String],
242    /// Plugin-provided path aliases (prefix, replacement).
243    pub path_aliases: &'a [(String, String)],
244    /// Absolute directories to search when resolving bare SCSS/Sass
245    /// `@import` / `@use` specifiers. Populated from Angular's
246    /// `stylePreprocessorOptions.includePaths` and equivalent settings.
247    pub scss_include_paths: &'a [PathBuf],
248    /// Static directory URL mappings from framework config.
249    /// Each tuple is `(absolute_source_dir, normalized_url_mount)`.
250    pub static_dir_mappings: &'a [(PathBuf, String)],
251    /// Project root directory.
252    pub root: &'a Path,
253    /// Lazy canonical path → FileId fallback for intra-project symlinks.
254    /// Only initialized on first miss when root is canonical. `None` when
255    /// path_to_id already uses canonical paths (root is not canonical).
256    pub canonical_fallback: Option<&'a CanonicalFallback<'a>>,
257    /// Dedup set for broken-tsconfig warnings. Emits one `tracing::warn!`
258    /// per unique error message instead of spamming the log with one
259    /// warning per affected file. Shared across all parallel resolver
260    /// threads via `Mutex`. Empty and unused when no tsconfig errors occur.
261    pub tsconfig_warned: &'a Mutex<FxHashSet<String>>,
262}
263
264/// Package manifest data used by source fallbacks.
265#[derive(Debug, Clone)]
266pub(super) struct PackageManifestInfo {
267    /// Package root path as discovered from the workspace tree.
268    pub root: PathBuf,
269    /// Canonical package root path for node_modules symlink comparisons.
270    pub canonical_root: PathBuf,
271    /// Parsed package name.
272    pub name: Option<String>,
273    /// Parsed package.json fields.
274    pub package_json: fallow_config::PackageJson,
275}
276
277/// Thread-safe lazy canonical path index, built on first access.
278pub(super) struct CanonicalFallback<'a> {
279    files: &'a [fallow_types::discover::DiscoveredFile],
280    map: std::sync::OnceLock<FxHashMap<std::path::PathBuf, FileId>>,
281}
282
283impl<'a> CanonicalFallback<'a> {
284    pub const fn new(files: &'a [fallow_types::discover::DiscoveredFile]) -> Self {
285        Self {
286            files,
287            map: std::sync::OnceLock::new(),
288        }
289    }
290
291    /// Look up a canonical path, lazily building the index on first call.
292    pub fn get(&self, canonical: &Path) -> Option<FileId> {
293        let map = self.map.get_or_init(|| {
294            tracing::debug!(
295                "intra-project symlinks detected, building canonical path index ({} files)",
296                self.files.len()
297            );
298            self.files
299                .iter()
300                .filter_map(|f| {
301                    dunce::canonicalize(&f.path)
302                        .ok()
303                        .map(|canonical| (canonical, f.id))
304                })
305                .collect()
306        });
307        map.get(canonical).copied()
308    }
309}
310
311#[cfg(all(test, not(miri)))]
312mod tests {
313    use super::*;
314    use fallow_types::discover::DiscoveredFile;
315
316    #[test]
317    fn canonical_fallback_returns_none_for_empty_files() {
318        let files: Vec<DiscoveredFile> = vec![];
319        let fallback = CanonicalFallback::new(&files);
320        assert!(fallback.get(Path::new("/nonexistent")).is_none());
321    }
322
323    #[test]
324    fn canonical_fallback_finds_existing_file() {
325        let temp = std::env::temp_dir().join("fallow-test-canonical-fallback");
326        let _ = std::fs::create_dir_all(&temp);
327        let test_file = temp.join("test.ts");
328        std::fs::write(&test_file, "").unwrap();
329
330        let files = vec![DiscoveredFile {
331            id: FileId(42),
332            path: test_file.clone(),
333            size_bytes: 0,
334        }];
335        let fallback = CanonicalFallback::new(&files);
336
337        let canonical = dunce::canonicalize(&test_file).unwrap();
338        assert_eq!(fallback.get(&canonical), Some(FileId(42)));
339
340        // Second call uses cached map (OnceLock)
341        assert_eq!(fallback.get(&canonical), Some(FileId(42)));
342
343        let _ = std::fs::remove_dir_all(&temp);
344    }
345
346    #[test]
347    fn canonical_fallback_returns_none_for_missing_path() {
348        let temp = std::env::temp_dir().join("fallow-test-canonical-miss");
349        let _ = std::fs::create_dir_all(&temp);
350        let test_file = temp.join("exists.ts");
351        std::fs::write(&test_file, "").unwrap();
352
353        let files = vec![DiscoveredFile {
354            id: FileId(1),
355            path: test_file,
356            size_bytes: 0,
357        }];
358        let fallback = CanonicalFallback::new(&files);
359        assert!(fallback.get(Path::new("/nonexistent/file.ts")).is_none());
360
361        let _ = std::fs::remove_dir_all(&temp);
362    }
363}
364
365/// Known output directory names that may appear in exports map targets.
366/// When an exports map points to `./dist/utils.js`, we try replacing these
367/// prefixes with `src/` (the conventional source directory) to find the tracked
368/// source file.
369pub const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
370
371/// Source extensions to try when mapping a built output file back to source.
372pub const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
373
374/// React Native platform extension prefixes.
375/// Metro resolves platform-specific files (e.g., `./foo` -> `./foo.web.tsx` on web).
376pub const RN_PLATFORM_PREFIXES: &[&str] = &[".web", ".ios", ".android", ".native"];