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 fob_graph::{Analyzer, Result};
41///
42/// # async fn example() -> 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: `path_alias("@", "./src")` makes "@/components/Button" resolve to "./src/components/Button".
113 pub fn path_alias(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
114 self.config.path_aliases.insert(from.into(), to.into());
115 self
116 }
117
118 /// Set multiple path aliases at once.
119 pub fn path_aliases(mut self, aliases: FxHashMap<String, String>) -> Self {
120 self.config.path_aliases = aliases;
121 self
122 }
123
124 /// Whether to follow dynamic imports (default: false).
125 pub fn follow_dynamic_imports(mut self, follow: bool) -> Self {
126 self.config.follow_dynamic_imports = follow;
127 self
128 }
129
130 /// Whether to include TypeScript type-only imports (default: true).
131 pub fn include_type_imports(mut self, include: bool) -> Self {
132 self.config.include_type_imports = include;
133 self
134 }
135
136 /// Set maximum depth for graph traversal (DoS protection).
137 ///
138 /// Default: 1000
139 pub fn max_depth(mut self, depth: Option<usize>) -> Self {
140 self.config.max_depth = depth;
141 self
142 }
143
144 /// Set maximum number of modules to process (DoS protection).
145 ///
146 /// Default: 100,000
147 pub fn max_modules(mut self, modules: Option<usize>) -> Self {
148 self.config.max_modules = modules;
149 self
150 }
151
152 /// Set the runtime for filesystem operations.
153 ///
154 /// If not set, will attempt to use a default runtime.
155 pub fn runtime(mut self, runtime: Arc<dyn Runtime>) -> Self {
156 self.config.runtime = Some(runtime);
157 self
158 }
159
160 /// Set the current working directory.
161 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
162 self.config.cwd = Some(cwd.into());
163 self
164 }
165}
166
167impl Analyzer<Configured> {
168 /// Execute the analysis with default options.
169 ///
170 /// Returns an `AnalysisResult` containing the module graph and statistics.
171 ///
172 /// This method is only available on `Analyzer<Configured>`, ensuring
173 /// that at least one entry point has been set.
174 pub async fn analyze(self) -> Result<AnalysisResult> {
175 self.analyze_with_options(AnalyzeOptions::default()).await
176 }
177
178 /// Execute the analysis with custom options.
179 ///
180 /// This method allows you to specify framework rules and control whether
181 /// usage counts are computed.
182 ///
183 /// # Arguments
184 ///
185 /// * `options` - Analysis options including framework rules and usage count settings
186 ///
187 /// # Returns
188 ///
189 /// An `AnalysisResult` containing the module graph and statistics.
190 ///
191 /// This method is only available on `Analyzer<Configured>`, ensuring
192 /// that at least one entry point has been set.
193 pub async fn analyze_with_options(self, options: AnalyzeOptions) -> Result<AnalysisResult> {
194 // Entries are guaranteed to exist by the typestate
195
196 // Get or create runtime
197 let runtime = self.get_runtime()?;
198
199 // Ensure cwd is set
200 let cwd = if self.config.cwd.is_some() {
201 self.config.cwd.clone()
202 } else {
203 runtime.get_cwd().ok()
204 };
205
206 let mut config = self.config;
207 config.cwd = cwd;
208
209 // Create walker and traverse graph
210 let walker = GraphWalker::new(config);
211 let collection = walker
212 .walk(runtime.clone())
213 .await
214 .map_err(|e| Error::Operation(format!("Graph walker failed: {}", e)))?;
215
216 // Build module graph from collected data
217 let graph = ModuleGraph::from_collected_data(collection)
218 .map_err(|e| Error::Operation(format!("Failed to build module graph: {}", e)))?;
219
220 // Apply framework rules if provided
221 if !options.framework_rules.is_empty() {
222 #[cfg(not(target_family = "wasm"))]
223 {
224 graph
225 .apply_framework_rules(options.framework_rules)
226 .await
227 .map_err(|e| {
228 Error::Operation(format!("Failed to apply framework rules: {}", e))
229 })?;
230 }
231 #[cfg(target_family = "wasm")]
232 {
233 // Framework rules require tokio runtime which isn't available in WASM
234 // Silently skip them in WASM environments
235 }
236 }
237
238 // Compute usage counts if requested
239 if options.compute_usage_counts {
240 graph
241 .compute_export_usage_counts()
242 .map_err(|e| Error::Operation(format!("Failed to compute usage counts: {}", e)))?;
243 }
244
245 // Compute statistics
246 let stats = compute_stats(&graph)?;
247 let entry_points = graph.entry_points()?;
248 let symbol_stats = graph.symbol_statistics()?;
249
250 Ok(AnalysisResult {
251 graph,
252 entry_points,
253 warnings: Vec::new(),
254 errors: Vec::new(),
255 stats,
256 symbol_stats,
257 })
258 }
259
260 /// Get or create a runtime instance.
261 fn get_runtime(&self) -> Result<Arc<dyn Runtime>> {
262 if let Some(ref runtime) = self.config.runtime {
263 Ok(Arc::clone(runtime))
264 } else {
265 // Try to use default runtime
266 #[cfg(not(target_family = "wasm"))]
267 {
268 use crate::NativeRuntime;
269 Ok(Arc::new(NativeRuntime))
270 }
271 #[cfg(target_family = "wasm")]
272 {
273 Err(crate::Error::InvalidConfig(
274 "Runtime is required in WASM environment".to_string(),
275 ))
276 }
277 }
278 }
279}
280
281impl Default for Analyzer<Unconfigured> {
282 fn default() -> Self {
283 Self::new()
284 }
285}
286
287// Type alias for backward compatibility
288/// Type alias for the default analyzer state (unconfigured).
289///
290/// For new code, prefer explicitly using `Analyzer<Unconfigured>` or
291/// `Analyzer<Configured>` to make the state clear.
292pub type AnalyzerDefault = Analyzer<Unconfigured>;
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[tokio::test]
299 async fn test_analyzer_builder() {
300 let analyzer = Analyzer::new()
301 .entry("src/index.ts")
302 .external(vec!["react"])
303 .path_alias("@", "./src")
304 .max_depth(Some(100));
305
306 assert_eq!(analyzer.config.entries.len(), 1);
307 assert_eq!(analyzer.config.external.len(), 1);
308 assert_eq!(analyzer.config.path_aliases.len(), 1);
309 assert_eq!(analyzer.config.max_depth, Some(100));
310 }
311
312 #[tokio::test]
313 async fn test_analyzer_typestate() {
314 // Unconfigured analyzer cannot call analyze()
315 let _unconfigured: Analyzer<Unconfigured> = Analyzer::new();
316 // This would be a compile error:
317 // let _ = unconfigured.analyze().await;
318
319 // Configured analyzer can call analyze()
320 let configured: Analyzer<Configured> = Analyzer::new().entry("src/index.ts");
321 // This compiles (though it will fail at runtime without proper setup)
322 let _result = configured.analyze().await;
323 }
324
325 #[tokio::test]
326 async fn test_analyzer_entries_transition() {
327 // entries() transitions from Unconfigured to Configured
328 let configured: Analyzer<Configured> = Analyzer::new().entries(vec!["src/index.ts"]);
329 let _result = configured.analyze().await;
330 }
331}