1pub mod analyze;
2pub mod cache;
3pub mod cross_reference;
4pub mod discover;
5pub mod duplicates;
6pub mod errors;
7pub mod extract;
8pub mod plugins;
9pub mod progress;
10pub mod results;
11pub mod scripts;
12pub mod suppress;
13pub mod trace;
14
15pub use fallow_graph::graph;
17pub use fallow_graph::project;
18pub use fallow_graph::resolve;
19
20use std::path::Path;
21use std::time::Instant;
22
23use errors::FallowError;
24use fallow_config::{PackageJson, ResolvedConfig, discover_workspaces};
25use results::AnalysisResults;
26use trace::PipelineTimings;
27
28pub struct AnalysisOutput {
30 pub results: AnalysisResults,
31 pub timings: Option<PipelineTimings>,
32 pub graph: Option<graph::ModuleGraph>,
33}
34
35fn update_cache(
37 store: &mut cache::CacheStore,
38 modules: &[extract::ModuleInfo],
39 files: &[discover::DiscoveredFile],
40) {
41 for module in modules {
42 if let Some(file) = files.get(module.file_id.0 as usize) {
43 let (mt, sz) = file_mtime_and_size(&file.path);
44 if let Some(cached) = store.get_by_path_only(&file.path)
46 && cached.content_hash == module.content_hash
47 {
48 if cached.mtime_secs != mt || cached.file_size != sz {
49 store.insert(&file.path, cache::module_to_cached(module, mt, sz));
50 }
51 continue;
52 }
53 store.insert(&file.path, cache::module_to_cached(module, mt, sz));
54 }
55 }
56 store.retain_paths(files);
57}
58
59fn file_mtime_and_size(path: &std::path::Path) -> (u64, u64) {
61 std::fs::metadata(path)
62 .map(|m| {
63 let mt = m
64 .modified()
65 .ok()
66 .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
67 .map_or(0, |d| d.as_secs());
68 (mt, m.len())
69 })
70 .unwrap_or((0, 0))
71}
72
73pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
75 let output = analyze_full(config, false, false)?;
76 Ok(output.results)
77}
78
79pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
81 let output = analyze_full(config, false, true)?;
82 Ok(output.results)
83}
84
85pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
87 analyze_full(config, true, false)
88}
89
90#[expect(clippy::unnecessary_wraps)] fn analyze_full(
92 config: &ResolvedConfig,
93 retain: bool,
94 collect_usages: bool,
95) -> Result<AnalysisOutput, FallowError> {
96 let _span = tracing::info_span!("fallow_analyze").entered();
97 let pipeline_start = Instant::now();
98
99 if !config.root.join("node_modules").is_dir() {
101 tracing::warn!(
102 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
103 );
104 }
105
106 let t = Instant::now();
108 let workspaces_vec = discover_workspaces(&config.root);
109 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
110 if !workspaces_vec.is_empty() {
111 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
112 }
113
114 let t = Instant::now();
116 let discovered_files = discover::discover_files(config);
117 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
118
119 let project = project::ProjectState::new(discovered_files, workspaces_vec);
122 let files = project.files();
123 let workspaces = project.workspaces();
124
125 let t = Instant::now();
127 let mut plugin_result = run_plugins(config, files, workspaces);
128 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
129
130 let t = Instant::now();
132 let pkg_path = config.root.join("package.json");
133 if let Ok(pkg) = PackageJson::load(&pkg_path)
134 && let Some(ref pkg_scripts) = pkg.scripts
135 {
136 let scripts_to_analyze = if config.production {
138 scripts::filter_production_scripts(pkg_scripts)
139 } else {
140 pkg_scripts.clone()
141 };
142 let script_analysis = scripts::analyze_scripts(&scripts_to_analyze, &config.root);
143 plugin_result.script_used_packages = script_analysis.used_packages;
144
145 for config_file in &script_analysis.config_files {
147 plugin_result.entry_patterns.push(config_file.clone());
148 }
149 }
150 for ws in workspaces {
152 let ws_pkg_path = ws.root.join("package.json");
153 if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path)
154 && let Some(ref ws_scripts) = ws_pkg.scripts
155 {
156 let scripts_to_analyze = if config.production {
157 scripts::filter_production_scripts(ws_scripts)
158 } else {
159 ws_scripts.clone()
160 };
161 let ws_analysis = scripts::analyze_scripts(&scripts_to_analyze, &ws.root);
162 plugin_result
163 .script_used_packages
164 .extend(ws_analysis.used_packages);
165
166 let ws_prefix = ws
167 .root
168 .strip_prefix(&config.root)
169 .unwrap_or(&ws.root)
170 .to_string_lossy();
171 for config_file in &ws_analysis.config_files {
172 plugin_result
173 .entry_patterns
174 .push(format!("{ws_prefix}/{config_file}"));
175 }
176 }
177 }
178 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
179
180 let t = Instant::now();
182 let mut cache_store = if config.no_cache {
183 None
184 } else {
185 cache::CacheStore::load(&config.cache_dir)
186 };
187
188 let parse_result = extract::parse_all_files(files, cache_store.as_ref());
189 let modules = parse_result.modules;
190 let cache_hits = parse_result.cache_hits;
191 let cache_misses = parse_result.cache_misses;
192 let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
193
194 let t = Instant::now();
196 if !config.no_cache {
197 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
198 update_cache(store, &modules, files);
199 if let Err(e) = store.save(&config.cache_dir) {
200 tracing::warn!("Failed to save cache: {e}");
201 }
202 }
203 let cache_ms = t.elapsed().as_secs_f64() * 1000.0;
204
205 let t = Instant::now();
207 let mut entry_points = discover::discover_entry_points(config, files);
208 for ws in workspaces {
209 let ws_entries = discover::discover_workspace_entry_points(&ws.root, config, files);
210 entry_points.extend(ws_entries);
211 }
212 let plugin_entries = discover::discover_plugin_entry_points(&plugin_result, config, files);
213 entry_points.extend(plugin_entries);
214 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
215
216 let t = Instant::now();
218 let resolved = resolve::resolve_all_imports(
219 &modules,
220 files,
221 workspaces,
222 &plugin_result.active_plugins,
223 &plugin_result.path_aliases,
224 &config.root,
225 );
226 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
227
228 let t = Instant::now();
230 let graph = graph::ModuleGraph::build(&resolved, &entry_points, files);
231 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
232
233 let t = Instant::now();
235 let result = analyze::find_dead_code_full(
236 &graph,
237 config,
238 &resolved,
239 Some(&plugin_result),
240 workspaces,
241 &modules,
242 collect_usages,
243 );
244 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
245
246 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
247
248 let cache_summary = if cache_hits > 0 {
249 format!(" ({cache_hits} cached, {cache_misses} parsed)")
250 } else {
251 String::new()
252 };
253
254 tracing::debug!(
255 "\n┌─ Pipeline Profile ─────────────────────────────\n\
256 │ discover files: {:>8.1}ms ({} files)\n\
257 │ workspaces: {:>8.1}ms\n\
258 │ plugins: {:>8.1}ms\n\
259 │ script analysis: {:>8.1}ms\n\
260 │ parse/extract: {:>8.1}ms ({} modules{})\n\
261 │ cache update: {:>8.1}ms\n\
262 │ entry points: {:>8.1}ms ({} entries)\n\
263 │ resolve imports: {:>8.1}ms\n\
264 │ build graph: {:>8.1}ms\n\
265 │ analyze: {:>8.1}ms\n\
266 │ ────────────────────────────────────────────\n\
267 │ TOTAL: {:>8.1}ms\n\
268 └─────────────────────────────────────────────────",
269 discover_ms,
270 files.len(),
271 workspaces_ms,
272 plugins_ms,
273 scripts_ms,
274 parse_ms,
275 modules.len(),
276 cache_summary,
277 cache_ms,
278 entry_points_ms,
279 entry_points.len(),
280 resolve_ms,
281 graph_ms,
282 analyze_ms,
283 total_ms,
284 );
285
286 let timings = if retain {
287 Some(PipelineTimings {
288 discover_files_ms: discover_ms,
289 file_count: files.len(),
290 workspaces_ms,
291 workspace_count: workspaces.len(),
292 plugins_ms,
293 script_analysis_ms: scripts_ms,
294 parse_extract_ms: parse_ms,
295 module_count: modules.len(),
296 cache_hits,
297 cache_misses,
298 cache_update_ms: cache_ms,
299 entry_points_ms,
300 entry_point_count: entry_points.len(),
301 resolve_imports_ms: resolve_ms,
302 build_graph_ms: graph_ms,
303 analyze_ms,
304 total_ms,
305 })
306 } else {
307 None
308 };
309
310 Ok(AnalysisOutput {
311 results: result,
312 timings,
313 graph: if retain { Some(graph) } else { None },
314 })
315}
316
317fn run_plugins(
319 config: &ResolvedConfig,
320 files: &[discover::DiscoveredFile],
321 workspaces: &[fallow_config::WorkspaceInfo],
322) -> plugins::AggregatedPluginResult {
323 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
324 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
325
326 let pkg_path = config.root.join("package.json");
328 let mut result = PackageJson::load(&pkg_path).map_or_else(
329 |_| plugins::AggregatedPluginResult::default(),
330 |pkg| registry.run(&pkg, &config.root, &file_paths),
331 );
332
333 if workspaces.is_empty() {
334 return result;
335 }
336
337 let precompiled_matchers = registry.precompile_config_matchers();
341 let relative_files: Vec<(&std::path::PathBuf, String)> = file_paths
342 .iter()
343 .map(|f| {
344 let rel = f
345 .strip_prefix(&config.root)
346 .unwrap_or(f)
347 .to_string_lossy()
348 .into_owned();
349 (f, rel)
350 })
351 .collect();
352
353 for ws in workspaces {
355 let ws_pkg_path = ws.root.join("package.json");
356 if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path) {
357 let ws_result = registry.run_workspace_fast(
358 &ws_pkg,
359 &ws.root,
360 &precompiled_matchers,
361 &relative_files,
362 );
363
364 if ws_result.active_plugins.is_empty() {
366 continue;
367 }
368
369 let ws_prefix = ws
374 .root
375 .strip_prefix(&config.root)
376 .unwrap_or(&ws.root)
377 .to_string_lossy();
378
379 for pat in &ws_result.entry_patterns {
380 result.entry_patterns.push(format!("{ws_prefix}/{pat}"));
381 }
382 for pat in &ws_result.always_used {
383 result.always_used.push(format!("{ws_prefix}/{pat}"));
384 }
385 for pat in &ws_result.discovered_always_used {
386 result
387 .discovered_always_used
388 .push(format!("{ws_prefix}/{pat}"));
389 }
390 for (file_pat, exports) in &ws_result.used_exports {
391 result
392 .used_exports
393 .push((format!("{ws_prefix}/{file_pat}"), exports.clone()));
394 }
395 for plugin_name in &ws_result.active_plugins {
397 if !result.active_plugins.contains(plugin_name) {
398 result.active_plugins.push(plugin_name.clone());
399 }
400 }
401 result
403 .referenced_dependencies
404 .extend(ws_result.referenced_dependencies);
405 result.setup_files.extend(ws_result.setup_files);
406 result
407 .tooling_dependencies
408 .extend(ws_result.tooling_dependencies);
409 for prefix in &ws_result.virtual_module_prefixes {
412 if !result.virtual_module_prefixes.contains(prefix) {
413 result.virtual_module_prefixes.push(prefix.clone());
414 }
415 }
416 }
417 }
418
419 result
420}
421
422pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
424 let config = default_config(root);
425 analyze_with_usages(&config)
426}
427
428pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
430 let user_config = fallow_config::FallowConfig::find_and_load(root)
431 .ok()
432 .flatten();
433 match user_config {
434 Some((config, _path)) => config.resolve(
435 root.to_path_buf(),
436 fallow_config::OutputFormat::Human,
437 num_cpus(),
438 false,
439 ),
440 None => fallow_config::FallowConfig {
441 schema: None,
442 extends: vec![],
443 entry: vec![],
444 ignore_patterns: vec![],
445 framework: vec![],
446 workspaces: None,
447 ignore_dependencies: vec![],
448 ignore_exports: vec![],
449 duplicates: fallow_config::DuplicatesConfig::default(),
450 rules: fallow_config::RulesConfig::default(),
451 production: false,
452 plugins: vec![],
453 overrides: vec![],
454 }
455 .resolve(
456 root.to_path_buf(),
457 fallow_config::OutputFormat::Human,
458 num_cpus(),
459 false,
460 ),
461 }
462}
463
464fn num_cpus() -> usize {
465 std::thread::available_parallelism()
466 .map(|n| n.get())
467 .unwrap_or(4)
468}