fob_graph/analysis/analyzer.rs
1//! Fast standalone analysis API without bundling.
2//!
3//! The Analyzer provides a lightweight way to analyze module graphs without
4//! the overhead of full bundling. It's ideal for:
5//! - IDE integration
6//! - CI/CD checks
7//! - Documentation generation
8//! - Dependency auditing
9
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use rustc_hash::FxHashMap;
14
15use super::{AnalyzeOptions, result::AnalysisResult, stats::compute_stats};
16use crate::ModuleGraph;
17use crate::runtime::Runtime;
18use crate::{Error, Result};
19
20use super::config::AnalyzerConfig;
21use super::walker::GraphWalker;
22
23/// Typestate marker for an unconfigured analyzer (no entry points yet).
24#[derive(Debug, Clone, Copy)]
25pub struct Unconfigured;
26
27/// Typestate marker for a configured analyzer (has entry points).
28#[derive(Debug, Clone, Copy)]
29pub struct Configured;
30
31/// Fast standalone analyzer for module graphs.
32///
33/// Uses the typestate pattern to ensure that analysis can only be performed
34/// after at least one entry point has been configured. This prevents runtime
35/// errors and makes the API more type-safe.
36///
37/// # Example
38///
39/// ```rust,no_run
40/// use super::analyzer::Analyzer;
41///
42/// # async fn example() -> crate::Result<()> {
43/// let analysis = Analyzer::new()
44/// .entry("src/index.ts") // Transitions to Configured state
45/// .external(vec!["react", "lodash"])
46/// .path_alias("@", "./src")
47/// .analyze() // Only available on Configured
48/// .await?;
49///
50/// println!("Unused exports: {}", analysis.unused_exports()?.len());
51/// # Ok(())
52/// # }
53/// ```
54pub struct Analyzer<State = Unconfigured> {
55 config: AnalyzerConfig,
56 _state: std::marker::PhantomData<State>,
57}
58
59impl Analyzer<Unconfigured> {
60 /// Create a new analyzer with default configuration.
61 ///
62 /// Returns an analyzer in the `Unconfigured` state. You must call `entry()`
63 /// before you can call `analyze()`.
64 pub fn new() -> Self {
65 Self {
66 config: AnalyzerConfig::default(),
67 _state: std::marker::PhantomData,
68 }
69 }
70
71 /// Add a single entry point.
72 ///
73 /// This transitions the analyzer to the `Configured` state, allowing
74 /// `analyze()` to be called.
75 pub fn entry(mut self, path: impl Into<PathBuf>) -> Analyzer<Configured> {
76 self.config.entries.push(path.into());
77 Analyzer {
78 config: self.config,
79 _state: std::marker::PhantomData,
80 }
81 }
82}
83
84impl<State> Analyzer<State> {
85 /// Add multiple entry points.
86 ///
87 /// This method is available in both `Unconfigured` and `Configured` states.
88 /// If called on `Unconfigured`, it transitions to `Configured`.
89 pub fn entries(
90 mut self,
91 paths: impl IntoIterator<Item = impl Into<PathBuf>>,
92 ) -> Analyzer<Configured> {
93 self.config
94 .entries
95 .extend(paths.into_iter().map(|p| p.into()));
96 Analyzer {
97 config: self.config,
98 _state: std::marker::PhantomData,
99 }
100 }
101
102 /// Mark packages as external (not analyzed).
103 pub fn external(mut self, packages: impl IntoIterator<Item = impl Into<String>>) -> Self {
104 self.config
105 .external
106 .extend(packages.into_iter().map(|p| p.into()));
107 self
108 }
109
110 /// Add a path alias for import resolution.
111 ///
112 /// # Example
113 ///
114 /// ```rust,no_run
115 /// use super::analyzer::Analyzer;
116 ///
117 /// Analyzer::new()
118 /// .entry("src/index.ts")
119 /// .path_alias("@", "./src");
120 /// // Now "@/components/Button" resolves to "./src/components/Button"
121 /// ```
122 pub fn path_alias(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
123 self.config.path_aliases.insert(from.into(), to.into());
124 self
125 }
126
127 /// Set multiple path aliases at once.
128 pub fn path_aliases(mut self, aliases: FxHashMap<String, String>) -> Self {
129 self.config.path_aliases = aliases;
130 self
131 }
132
133 /// Whether to follow dynamic imports (default: false).
134 pub fn follow_dynamic_imports(mut self, follow: bool) -> Self {
135 self.config.follow_dynamic_imports = follow;
136 self
137 }
138
139 /// Whether to include TypeScript type-only imports (default: true).
140 pub fn include_type_imports(mut self, include: bool) -> Self {
141 self.config.include_type_imports = include;
142 self
143 }
144
145 /// Set maximum depth for graph traversal (DoS protection).
146 ///
147 /// Default: 1000
148 pub fn max_depth(mut self, depth: Option<usize>) -> Self {
149 self.config.max_depth = depth;
150 self
151 }
152
153 /// Set maximum number of modules to process (DoS protection).
154 ///
155 /// Default: 100,000
156 pub fn max_modules(mut self, modules: Option<usize>) -> Self {
157 self.config.max_modules = modules;
158 self
159 }
160
161 /// Set the runtime for filesystem operations.
162 ///
163 /// If not set, will attempt to use a default runtime.
164 pub fn runtime(mut self, runtime: Arc<dyn Runtime>) -> Self {
165 self.config.runtime = Some(runtime);
166 self
167 }
168
169 /// Set the current working directory.
170 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
171 self.config.cwd = Some(cwd.into());
172 self
173 }
174}
175
176impl Analyzer<Configured> {
177 /// Execute the analysis with default options.
178 ///
179 /// Returns an `AnalysisResult` containing the module graph and statistics.
180 ///
181 /// This method is only available on `Analyzer<Configured>`, ensuring
182 /// that at least one entry point has been set.
183 pub async fn analyze(self) -> Result<AnalysisResult> {
184 self.analyze_with_options(AnalyzeOptions::default()).await
185 }
186
187 /// Execute the analysis with custom options.
188 ///
189 /// This method allows you to specify framework rules and control whether
190 /// usage counts are computed.
191 ///
192 /// # Arguments
193 ///
194 /// * `options` - Analysis options including framework rules and usage count settings
195 ///
196 /// # Returns
197 ///
198 /// An `AnalysisResult` containing the module graph and statistics.
199 ///
200 /// This method is only available on `Analyzer<Configured>`, ensuring
201 /// that at least one entry point has been set.
202 pub async fn analyze_with_options(self, options: AnalyzeOptions) -> Result<AnalysisResult> {
203 // Entries are guaranteed to exist by the typestate
204
205 // Get or create runtime
206 let runtime = self.get_runtime()?;
207
208 // Ensure cwd is set
209 let cwd = if self.config.cwd.is_some() {
210 self.config.cwd.clone()
211 } else {
212 runtime.get_cwd().ok()
213 };
214
215 let mut config = self.config;
216 config.cwd = cwd;
217
218 // Create walker and traverse graph
219 let walker = GraphWalker::new(config);
220 let collection = walker
221 .walk(runtime.clone())
222 .await
223 .map_err(|e| Error::Operation(format!("Graph walker failed: {}", e)))?;
224
225 // Build module graph from collected data
226 let graph = ModuleGraph::from_collected_data(collection)
227 .map_err(|e| Error::Operation(format!("Failed to build module graph: {}", e)))?;
228
229 // Apply framework rules if provided
230 if !options.framework_rules.is_empty() {
231 #[cfg(not(target_family = "wasm"))]
232 {
233 graph
234 .apply_framework_rules(options.framework_rules)
235 .map_err(|e| {
236 Error::Operation(format!("Failed to apply framework rules: {}", e))
237 })?;
238 }
239 #[cfg(target_family = "wasm")]
240 {
241 // Framework rules require tokio runtime which isn't available in WASM
242 // Silently skip them in WASM environments
243 }
244 }
245
246 // Compute usage counts if requested
247 if options.compute_usage_counts {
248 graph
249 .compute_export_usage_counts()
250 .map_err(|e| Error::Operation(format!("Failed to compute usage counts: {}", e)))?;
251 }
252
253 // Compute statistics
254 let stats = compute_stats(&graph)?;
255 let entry_points = graph.entry_points()?;
256 let symbol_stats = graph.symbol_statistics()?;
257
258 Ok(AnalysisResult {
259 graph,
260 entry_points,
261 warnings: Vec::new(),
262 errors: Vec::new(),
263 stats,
264 symbol_stats,
265 })
266 }
267
268 /// Get or create a runtime instance.
269 fn get_runtime(&self) -> Result<Arc<dyn Runtime>> {
270 if let Some(ref runtime) = self.config.runtime {
271 Ok(Arc::clone(runtime))
272 } else {
273 // Try to use default runtime
274 #[cfg(not(target_family = "wasm"))]
275 {
276 use crate::NativeRuntime;
277 Ok(Arc::new(NativeRuntime))
278 }
279 #[cfg(target_family = "wasm")]
280 {
281 Err(crate::Error::InvalidConfig(
282 "Runtime is required in WASM environment".to_string(),
283 ))
284 }
285 }
286 }
287}
288
289impl Default for Analyzer<Unconfigured> {
290 fn default() -> Self {
291 Self::new()
292 }
293}
294
295// Type alias for backward compatibility
296/// Type alias for the default analyzer state (unconfigured).
297///
298/// For new code, prefer explicitly using `Analyzer<Unconfigured>` or
299/// `Analyzer<Configured>` to make the state clear.
300pub type AnalyzerDefault = Analyzer<Unconfigured>;
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[tokio::test]
307 async fn test_analyzer_builder() {
308 let analyzer = Analyzer::new()
309 .entry("src/index.ts")
310 .external(vec!["react"])
311 .path_alias("@", "./src")
312 .max_depth(Some(100));
313
314 assert_eq!(analyzer.config.entries.len(), 1);
315 assert_eq!(analyzer.config.external.len(), 1);
316 assert_eq!(analyzer.config.path_aliases.len(), 1);
317 assert_eq!(analyzer.config.max_depth, Some(100));
318 }
319
320 #[tokio::test]
321 async fn test_analyzer_typestate() {
322 // Unconfigured analyzer cannot call analyze()
323 let _unconfigured: Analyzer<Unconfigured> = Analyzer::new();
324 // This would be a compile error:
325 // let _ = unconfigured.analyze().await;
326
327 // Configured analyzer can call analyze()
328 let configured: Analyzer<Configured> = Analyzer::new().entry("src/index.ts");
329 // This compiles (though it will fail at runtime without proper setup)
330 let _result = configured.analyze().await;
331 }
332
333 #[tokio::test]
334 async fn test_analyzer_entries_transition() {
335 // entries() transitions from Unconfigured to Configured
336 let configured: Analyzer<Configured> = Analyzer::new().entries(vec!["src/index.ts"]);
337 let _result = configured.analyze().await;
338 }
339}