Skip to main content

fallow_core/
lib.rs

1pub mod analyze;
2pub mod cache;
3pub mod discover;
4pub mod duplicates;
5pub mod errors;
6pub mod extract;
7pub mod graph;
8pub mod plugins;
9pub mod progress;
10pub mod resolve;
11pub mod results;
12pub mod scripts;
13pub mod suppress;
14
15use std::path::Path;
16use std::time::Instant;
17
18use errors::FallowError;
19use fallow_config::{PackageJson, ResolvedConfig, discover_workspaces};
20use results::AnalysisResults;
21
22/// Run the full analysis pipeline.
23pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
24    let _span = tracing::info_span!("fallow_analyze").entered();
25    let pipeline_start = Instant::now();
26
27    // Warn if node_modules is missing — resolution will be severely degraded
28    if !config.root.join("node_modules").is_dir() {
29        tracing::warn!(
30            "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
31        );
32    }
33
34    // Discover workspaces if in a monorepo
35    let t = Instant::now();
36    let workspaces = discover_workspaces(&config.root);
37    let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
38    if !workspaces.is_empty() {
39        tracing::info!(count = workspaces.len(), "workspaces discovered");
40    }
41
42    // Stage 1: Discover all source files
43    let t = Instant::now();
44    let files = discover::discover_files(config);
45    let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
46
47    // Stage 1.5: Run plugin system — parse config files, discover dynamic entries
48    let t = Instant::now();
49    let mut plugin_result = run_plugins(config, &files, &workspaces);
50    let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
51
52    // Stage 1.6: Analyze package.json scripts for binary usage and config file refs
53    let t = Instant::now();
54    let pkg_path = config.root.join("package.json");
55    if let Ok(pkg) = PackageJson::load(&pkg_path)
56        && let Some(ref pkg_scripts) = pkg.scripts
57    {
58        // In production mode, only analyze start/build scripts
59        let scripts_to_analyze = if config.production {
60            scripts::filter_production_scripts(pkg_scripts)
61        } else {
62            pkg_scripts.clone()
63        };
64        let script_analysis = scripts::analyze_scripts(&scripts_to_analyze, &config.root);
65        plugin_result.script_used_packages = script_analysis.used_packages;
66
67        // Add config files from scripts as entry points (resolved later)
68        for config_file in &script_analysis.config_files {
69            plugin_result.entry_patterns.push(config_file.clone());
70        }
71    }
72    // Also analyze workspace package.json scripts
73    for ws in &workspaces {
74        let ws_pkg_path = ws.root.join("package.json");
75        if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path)
76            && let Some(ref ws_scripts) = ws_pkg.scripts
77        {
78            let scripts_to_analyze = if config.production {
79                scripts::filter_production_scripts(ws_scripts)
80            } else {
81                ws_scripts.clone()
82            };
83            let ws_analysis = scripts::analyze_scripts(&scripts_to_analyze, &ws.root);
84            plugin_result
85                .script_used_packages
86                .extend(ws_analysis.used_packages);
87
88            let ws_prefix = ws
89                .root
90                .strip_prefix(&config.root)
91                .unwrap_or(&ws.root)
92                .to_string_lossy();
93            for config_file in &ws_analysis.config_files {
94                plugin_result
95                    .entry_patterns
96                    .push(format!("{ws_prefix}/{config_file}"));
97            }
98        }
99    }
100    let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
101
102    // Stage 2: Parse all files in parallel and extract imports/exports
103    let t = Instant::now();
104    let mut cache_store = if config.no_cache {
105        None
106    } else {
107        cache::CacheStore::load(&config.cache_dir)
108    };
109
110    let modules = extract::parse_all_files(&files, config, cache_store.as_ref());
111    let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
112
113    // Update cache with parsed results
114    let t = Instant::now();
115    if !config.no_cache {
116        let store = cache_store.get_or_insert_with(cache::CacheStore::new);
117        for module in &modules {
118            if let Some(file) = files.get(module.file_id.0 as usize) {
119                store.insert(&file.path, cache::module_to_cached(module));
120            }
121        }
122        if let Err(e) = store.save(&config.cache_dir) {
123            tracing::warn!("Failed to save cache: {e}");
124        }
125    }
126    let cache_ms = t.elapsed().as_secs_f64() * 1000.0;
127
128    // Stage 3: Discover entry points (static patterns + plugin-discovered patterns)
129    let t = Instant::now();
130    let mut entry_points = discover::discover_entry_points(config, &files);
131    for ws in &workspaces {
132        let ws_entries = discover::discover_workspace_entry_points(&ws.root, config, &files);
133        entry_points.extend(ws_entries);
134    }
135    let plugin_entries = discover::discover_plugin_entry_points(&plugin_result, config, &files);
136    entry_points.extend(plugin_entries);
137    let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
138
139    // Stage 4: Resolve imports to file IDs
140    let t = Instant::now();
141    let resolved = resolve::resolve_all_imports(&modules, config, &files);
142    let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
143
144    // Stage 5: Build module graph
145    let t = Instant::now();
146    let graph = graph::ModuleGraph::build(&resolved, &entry_points, &files);
147    let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
148
149    // Stage 6: Analyze for dead code (with plugin context and workspace info)
150    let t = Instant::now();
151    let result = analyze::find_dead_code_full(
152        &graph,
153        config,
154        &resolved,
155        Some(&plugin_result),
156        &workspaces,
157        &modules,
158    );
159    let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
160
161    let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
162
163    tracing::debug!(
164        "\n┌─ Pipeline Profile ─────────────────────────────\n\
165         │  discover files:   {:>8.1}ms  ({} files)\n\
166         │  workspaces:       {:>8.1}ms\n\
167         │  plugins:          {:>8.1}ms\n\
168         │  script analysis:  {:>8.1}ms\n\
169         │  parse/extract:    {:>8.1}ms  ({} modules)\n\
170         │  cache update:     {:>8.1}ms\n\
171         │  entry points:     {:>8.1}ms  ({} entries)\n\
172         │  resolve imports:  {:>8.1}ms\n\
173         │  build graph:      {:>8.1}ms\n\
174         │  analyze:          {:>8.1}ms\n\
175         │  ────────────────────────────────────────────\n\
176         │  TOTAL:            {:>8.1}ms\n\
177         └─────────────────────────────────────────────────",
178        discover_ms,
179        files.len(),
180        workspaces_ms,
181        plugins_ms,
182        scripts_ms,
183        parse_ms,
184        modules.len(),
185        cache_ms,
186        entry_points_ms,
187        entry_points.len(),
188        resolve_ms,
189        graph_ms,
190        analyze_ms,
191        total_ms,
192    );
193
194    Ok(result)
195}
196
197/// Run plugins for root project and all workspace packages.
198fn run_plugins(
199    config: &ResolvedConfig,
200    files: &[discover::DiscoveredFile],
201    workspaces: &[fallow_config::WorkspaceInfo],
202) -> plugins::AggregatedPluginResult {
203    let registry = plugins::PluginRegistry::new();
204    let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
205
206    // Run plugins for root project
207    let pkg_path = config.root.join("package.json");
208    let mut result = if let Ok(pkg) = PackageJson::load(&pkg_path) {
209        registry.run(&pkg, &config.root, &file_paths)
210    } else {
211        plugins::AggregatedPluginResult::default()
212    };
213
214    // Run plugins for each workspace package too
215    for ws in workspaces {
216        let ws_pkg_path = ws.root.join("package.json");
217        if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path) {
218            let ws_result = registry.run(&ws_pkg, &ws.root, &file_paths);
219
220            // Workspace plugin patterns are relative to the workspace root (e.g., `jest.setup.ts`),
221            // but `discover_plugin_entry_points` matches against paths relative to the monorepo root
222            // (e.g., `packages/foo/jest.setup.ts`). Prefix workspace patterns with the workspace
223            // path to make them matchable from the monorepo root.
224            let ws_prefix = ws
225                .root
226                .strip_prefix(&config.root)
227                .unwrap_or(&ws.root)
228                .to_string_lossy();
229
230            for pat in &ws_result.entry_patterns {
231                result.entry_patterns.push(format!("{ws_prefix}/{pat}"));
232            }
233            for pat in &ws_result.always_used {
234                result.always_used.push(format!("{ws_prefix}/{pat}"));
235            }
236            for pat in &ws_result.discovered_always_used {
237                result
238                    .discovered_always_used
239                    .push(format!("{ws_prefix}/{pat}"));
240            }
241            for (file_pat, exports) in &ws_result.used_exports {
242                result
243                    .used_exports
244                    .push((format!("{ws_prefix}/{file_pat}"), exports.clone()));
245            }
246            // Merge active plugin names (deduplicated)
247            for plugin_name in &ws_result.active_plugins {
248                if !result.active_plugins.contains(plugin_name) {
249                    result.active_plugins.push(plugin_name.clone());
250                }
251            }
252            // These don't need prefixing (absolute paths / package names)
253            result
254                .referenced_dependencies
255                .extend(ws_result.referenced_dependencies);
256            result.setup_files.extend(ws_result.setup_files);
257            result
258                .tooling_dependencies
259                .extend(ws_result.tooling_dependencies);
260        }
261    }
262
263    result
264}
265
266/// Run analysis on a project directory.
267pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
268    let config = default_config(root);
269    analyze(&config)
270}
271
272/// Create a default config for a project root.
273pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
274    let user_config = fallow_config::FallowConfig::find_and_load(root)
275        .ok()
276        .flatten();
277    match user_config {
278        Some((config, _path)) => config.resolve(root.to_path_buf(), num_cpus(), false),
279        None => fallow_config::FallowConfig {
280            schema: None,
281            entry: vec![],
282            ignore: vec![],
283            detect: fallow_config::DetectConfig::default(),
284            framework: vec![],
285            workspaces: None,
286            ignore_dependencies: vec![],
287            ignore_exports: vec![],
288            output: fallow_config::OutputFormat::Human,
289            duplicates: fallow_config::DuplicatesConfig::default(),
290            rules: fallow_config::RulesConfig::default(),
291            production: false,
292        }
293        .resolve(root.to_path_buf(), num_cpus(), false),
294    }
295}
296
297fn num_cpus() -> usize {
298    std::thread::available_parallelism()
299        .map(|n| n.get())
300        .unwrap_or(4)
301}