fob_graph/memory/exports.rs
1//! Export analysis methods for ModuleGraph.
2
3use std::sync::Arc;
4
5use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
6
7use super::super::import::{ImportKind, ImportSpecifier};
8use super::super::{ExportKind, Module, ModuleId};
9use super::graph::{GraphInner, ModuleGraph};
10use crate::{Error, Result};
11
12impl ModuleGraph {
13 /// Discover unused exports, respecting framework markers and namespace imports.
14 pub fn unused_exports(&self) -> Result<Vec<super::super::UnusedExport>> {
15 let inner = self.inner.read();
16 let mut unused = Vec::new();
17
18 for module in inner.modules.values() {
19 if module.is_entry {
20 continue;
21 }
22
23 for export in module.exports.iter() {
24 if export.is_framework_used {
25 continue;
26 }
27
28 if !Self::is_export_used_inner(&inner, &module.id, &export.name)? {
29 unused.push(super::super::UnusedExport {
30 module_id: module.id.clone(),
31 export: export.clone(),
32 });
33 }
34 }
35 }
36
37 Ok(unused)
38 }
39
40 pub(super) fn is_export_used_inner(
41 inner: &GraphInner,
42 module_id: &ModuleId,
43 export_name: &str,
44 ) -> Result<bool> {
45 let dependents = inner.dependents.get(module_id).cloned().unwrap_or_default();
46
47 for importer_id in dependents {
48 if let Some(importer) = inner.modules.get(&importer_id) {
49 for import_record in importer.imports.iter() {
50 if import_record.resolved_to.as_ref() != Some(module_id) {
51 continue;
52 }
53
54 if import_record.specifiers.is_empty() {
55 // Side-effect import does not use exports.
56 continue;
57 }
58
59 let is_used =
60 import_record
61 .specifiers
62 .iter()
63 .any(|specifier| match specifier {
64 ImportSpecifier::Named(name) => name == export_name,
65 ImportSpecifier::Default => export_name == "default",
66 ImportSpecifier::Namespace(_) => {
67 // True namespace imports (import * as X) use ALL exports
68 // But star re-exports (export * from) only forward, not use
69 !matches!(import_record.kind, ImportKind::ReExport)
70 }
71 });
72
73 if is_used {
74 return Ok(true);
75 }
76 }
77 }
78 }
79
80 // Check if this export is re-exported by other modules and used transitively
81 // This handles cases like: validators.ts exports validateEmail -> helpers.ts does
82 // export * from validators.ts -> demo.tsx imports { validateEmail } from helpers.ts
83
84 // Get the source module's path for comparison (re_exported_from uses path, not module ID)
85 let source_module = inner.modules.get(module_id).ok_or_else(|| {
86 Error::InvalidConfig(format!("Module {} not found in graph", module_id))
87 })?;
88 let source_path = source_module.path.to_string_lossy();
89
90 for (re_exporter_id, re_exporter_module) in &inner.modules {
91 for export in re_exporter_module.exports.iter() {
92 match export.kind {
93 ExportKind::StarReExport => {
94 // Star re-export: check if it's from our module
95 if let Some(ref re_exported_from) = export.re_exported_from {
96 if re_exported_from == source_path.as_ref() {
97 // This module re-exports all exports from our module
98 // Recursively check if this re-exporting module's export is used
99 if Self::is_export_used_inner(inner, re_exporter_id, export_name)? {
100 return Ok(true);
101 }
102 }
103 }
104 }
105 ExportKind::ReExport => {
106 // Named re-export: check if it matches our export
107 if export.name == export_name {
108 if let Some(ref re_exported_from) = export.re_exported_from {
109 if re_exported_from == source_path.as_ref() {
110 // This is a named re-export of our specific export
111 // Recursively check if THIS re-export is used
112 if Self::is_export_used_inner(
113 inner,
114 re_exporter_id,
115 &export.name,
116 )? {
117 return Ok(true);
118 }
119 }
120 }
121 }
122 }
123 _ => {
124 // Named, Default, TypeOnly - not re-exports, skip
125 }
126 }
127 }
128 }
129
130 Ok(false)
131 }
132
133 /// Computes and sets usage counts for all exports in the module graph.
134 ///
135 /// For each export in each module, this counts how many times it's imported
136 /// across all dependent modules and updates the `usage_count` field.
137 ///
138 /// Usage counts are determined by:
139 /// - Named imports: Each `import { foo }` increments the count for export "foo"
140 /// - Default imports: Each `import foo` increments the count for export "default"
141 /// - Namespace imports: Each `import * as ns` increments the count for ALL exports by 1
142 /// (except star re-exports which only forward, not consume)
143 /// - Re-exports: Counted separately as they create new import paths
144 ///
145 /// After calling this method, each Export will have `usage_count` set to:
146 /// - `Some(0)` if the export is unused
147 /// - `Some(n)` where n > 0 for the number of import sites
148 pub fn compute_export_usage_counts(&self) -> Result<()> {
149 // 1. Snapshot only IDs (not full HashMap)
150 let module_ids: Vec<ModuleId> = {
151 let inner = self.inner.read();
152 inner.modules.keys().cloned().collect()
153 };
154
155 // 2. Process each module with brief read locks
156 let mut updates = HashMap::default();
157 for module_id in module_ids {
158 let (module, dependents) = {
159 let inner = self.inner.read();
160 // Skip modules that were removed concurrently
161 let Some(module_arc) = inner.modules.get(&module_id) else {
162 continue;
163 };
164 let module = (**module_arc).clone();
165 let dependents = inner
166 .dependents
167 .get(&module_id)
168 .cloned()
169 .unwrap_or_default();
170 (module, dependents)
171 }; // Lock released here
172
173 let mut updated_module = module;
174 // Use Arc::make_mut to get mutable access to exports
175 let exports = std::sync::Arc::make_mut(&mut updated_module.exports);
176 for export in exports.iter_mut() {
177 let count = {
178 // Get importer modules for counting
179 let inner = self.inner.read();
180 Self::count_export_usage_standalone(
181 &inner.modules,
182 &module_id,
183 &export.name,
184 &dependents,
185 )?
186 };
187 export.set_usage_count(count);
188 }
189
190 updates.insert(module_id, std::sync::Arc::new(updated_module));
191 }
192
193 // 3. Apply updates with single write lock
194 {
195 let mut inner = self.inner.write();
196 for (id, module) in updates {
197 inner.modules.insert(id, module);
198 }
199 }
200
201 Ok(())
202 }
203
204 /// Standalone helper to count export usage.
205 ///
206 /// This works with a read lock on modules HashMap, counting how many times
207 /// an export is imported by dependent modules.
208 fn count_export_usage_standalone(
209 modules: &HashMap<ModuleId, Arc<Module>>,
210 module_id: &ModuleId,
211 export_name: &str,
212 dependents: &HashSet<ModuleId>,
213 ) -> Result<usize> {
214 let mut count = 0;
215
216 for importer_id in dependents {
217 if let Some(importer) = modules.get(importer_id) {
218 for import_record in importer.imports.iter() {
219 if import_record.resolved_to.as_ref() != Some(module_id) {
220 continue;
221 }
222
223 if import_record.specifiers.is_empty() {
224 // Side-effect import does not use exports.
225 continue;
226 }
227
228 // Count matching specifiers
229 for specifier in &import_record.specifiers {
230 let matches = match specifier {
231 ImportSpecifier::Named(name) => name == export_name,
232 ImportSpecifier::Default => export_name == "default",
233 ImportSpecifier::Namespace(_) => {
234 // Namespace imports (import * as X) use ALL exports once
235 // But star re-exports (export * from) only forward, not use
236 !matches!(import_record.kind, ImportKind::ReExport)
237 }
238 };
239
240 if matches {
241 count += 1;
242 // For namespace imports, we only count once per import statement
243 // not once per export, so we break here
244 if matches!(specifier, ImportSpecifier::Namespace(_)) {
245 break;
246 }
247 }
248 }
249 }
250 }
251 }
252
253 Ok(count)
254 }
255}