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}