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}