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
148 .entry_patterns
149 .push((config_file.clone(), "scripts".to_string()));
150 }
151 }
152 for ws in workspaces {
154 let ws_pkg_path = ws.root.join("package.json");
155 if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path)
156 && let Some(ref ws_scripts) = ws_pkg.scripts
157 {
158 let scripts_to_analyze = if config.production {
159 scripts::filter_production_scripts(ws_scripts)
160 } else {
161 ws_scripts.clone()
162 };
163 let ws_analysis = scripts::analyze_scripts(&scripts_to_analyze, &ws.root);
164 plugin_result
165 .script_used_packages
166 .extend(ws_analysis.used_packages);
167
168 let ws_prefix = ws
169 .root
170 .strip_prefix(&config.root)
171 .unwrap_or(&ws.root)
172 .to_string_lossy();
173 for config_file in &ws_analysis.config_files {
174 plugin_result
175 .entry_patterns
176 .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
177 }
178 }
179 }
180 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
181
182 let t = Instant::now();
184 let mut cache_store = if config.no_cache {
185 None
186 } else {
187 cache::CacheStore::load(&config.cache_dir)
188 };
189
190 let parse_result = extract::parse_all_files(files, cache_store.as_ref());
191 let modules = parse_result.modules;
192 let cache_hits = parse_result.cache_hits;
193 let cache_misses = parse_result.cache_misses;
194 let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
195
196 let t = Instant::now();
198 if !config.no_cache {
199 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
200 update_cache(store, &modules, files);
201 if let Err(e) = store.save(&config.cache_dir) {
202 tracing::warn!("Failed to save cache: {e}");
203 }
204 }
205 let cache_ms = t.elapsed().as_secs_f64() * 1000.0;
206
207 let t = Instant::now();
209 let mut entry_points = discover::discover_entry_points(config, files);
210 for ws in workspaces {
211 let ws_entries = discover::discover_workspace_entry_points(&ws.root, config, files);
212 entry_points.extend(ws_entries);
213 }
214 let plugin_entries = discover::discover_plugin_entry_points(&plugin_result, config, files);
215 entry_points.extend(plugin_entries);
216 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
217 entry_points.extend(infra_entries);
218 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
219
220 let t = Instant::now();
222 let resolved = resolve::resolve_all_imports(
223 &modules,
224 files,
225 workspaces,
226 &plugin_result.active_plugins,
227 &plugin_result.path_aliases,
228 &config.root,
229 );
230 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
231
232 let t = Instant::now();
234 let graph = graph::ModuleGraph::build(&resolved, &entry_points, files);
235 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
236
237 let t = Instant::now();
239 let result = analyze::find_dead_code_full(
240 &graph,
241 config,
242 &resolved,
243 Some(&plugin_result),
244 workspaces,
245 &modules,
246 collect_usages,
247 );
248 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
249
250 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
251
252 let cache_summary = if cache_hits > 0 {
253 format!(" ({cache_hits} cached, {cache_misses} parsed)")
254 } else {
255 String::new()
256 };
257
258 tracing::debug!(
259 "\n┌─ Pipeline Profile ─────────────────────────────\n\
260 │ discover files: {:>8.1}ms ({} files)\n\
261 │ workspaces: {:>8.1}ms\n\
262 │ plugins: {:>8.1}ms\n\
263 │ script analysis: {:>8.1}ms\n\
264 │ parse/extract: {:>8.1}ms ({} modules{})\n\
265 │ cache update: {:>8.1}ms\n\
266 │ entry points: {:>8.1}ms ({} entries)\n\
267 │ resolve imports: {:>8.1}ms\n\
268 │ build graph: {:>8.1}ms\n\
269 │ analyze: {:>8.1}ms\n\
270 │ ────────────────────────────────────────────\n\
271 │ TOTAL: {:>8.1}ms\n\
272 └─────────────────────────────────────────────────",
273 discover_ms,
274 files.len(),
275 workspaces_ms,
276 plugins_ms,
277 scripts_ms,
278 parse_ms,
279 modules.len(),
280 cache_summary,
281 cache_ms,
282 entry_points_ms,
283 entry_points.len(),
284 resolve_ms,
285 graph_ms,
286 analyze_ms,
287 total_ms,
288 );
289
290 let timings = if retain {
291 Some(PipelineTimings {
292 discover_files_ms: discover_ms,
293 file_count: files.len(),
294 workspaces_ms,
295 workspace_count: workspaces.len(),
296 plugins_ms,
297 script_analysis_ms: scripts_ms,
298 parse_extract_ms: parse_ms,
299 module_count: modules.len(),
300 cache_hits,
301 cache_misses,
302 cache_update_ms: cache_ms,
303 entry_points_ms,
304 entry_point_count: entry_points.len(),
305 resolve_imports_ms: resolve_ms,
306 build_graph_ms: graph_ms,
307 analyze_ms,
308 total_ms,
309 })
310 } else {
311 None
312 };
313
314 Ok(AnalysisOutput {
315 results: result,
316 timings,
317 graph: if retain { Some(graph) } else { None },
318 })
319}
320
321fn run_plugins(
323 config: &ResolvedConfig,
324 files: &[discover::DiscoveredFile],
325 workspaces: &[fallow_config::WorkspaceInfo],
326) -> plugins::AggregatedPluginResult {
327 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
328 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
329
330 let pkg_path = config.root.join("package.json");
332 let mut result = PackageJson::load(&pkg_path).map_or_else(
333 |_| plugins::AggregatedPluginResult::default(),
334 |pkg| registry.run(&pkg, &config.root, &file_paths),
335 );
336
337 if workspaces.is_empty() {
338 return result;
339 }
340
341 let precompiled_matchers = registry.precompile_config_matchers();
345 let relative_files: Vec<(&std::path::PathBuf, String)> = file_paths
346 .iter()
347 .map(|f| {
348 let rel = f
349 .strip_prefix(&config.root)
350 .unwrap_or(f)
351 .to_string_lossy()
352 .into_owned();
353 (f, rel)
354 })
355 .collect();
356
357 for ws in workspaces {
359 let ws_pkg_path = ws.root.join("package.json");
360 if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path) {
361 let ws_result = registry.run_workspace_fast(
362 &ws_pkg,
363 &ws.root,
364 &config.root,
365 &precompiled_matchers,
366 &relative_files,
367 );
368
369 if ws_result.active_plugins.is_empty() {
371 continue;
372 }
373
374 let ws_prefix = ws
379 .root
380 .strip_prefix(&config.root)
381 .unwrap_or(&ws.root)
382 .to_string_lossy();
383
384 let prefix_if_needed = |pat: &str| -> String {
389 if pat.starts_with(ws_prefix.as_ref()) || pat.starts_with('/') {
390 pat.to_string()
391 } else {
392 format!("{ws_prefix}/{pat}")
393 }
394 };
395
396 for (pat, pname) in &ws_result.entry_patterns {
397 result
398 .entry_patterns
399 .push((prefix_if_needed(pat), pname.clone()));
400 }
401 for (pat, pname) in &ws_result.always_used {
402 result
403 .always_used
404 .push((prefix_if_needed(pat), pname.clone()));
405 }
406 for (pat, pname) in &ws_result.discovered_always_used {
407 result
408 .discovered_always_used
409 .push((prefix_if_needed(pat), pname.clone()));
410 }
411 for (file_pat, exports) in &ws_result.used_exports {
412 result
413 .used_exports
414 .push((prefix_if_needed(file_pat), exports.clone()));
415 }
416 for plugin_name in &ws_result.active_plugins {
418 if !result.active_plugins.contains(plugin_name) {
419 result.active_plugins.push(plugin_name.clone());
420 }
421 }
422 result
424 .referenced_dependencies
425 .extend(ws_result.referenced_dependencies);
426 result.setup_files.extend(ws_result.setup_files);
427 result
428 .tooling_dependencies
429 .extend(ws_result.tooling_dependencies);
430 for prefix in &ws_result.virtual_module_prefixes {
433 if !result.virtual_module_prefixes.contains(prefix) {
434 result.virtual_module_prefixes.push(prefix.clone());
435 }
436 }
437 }
438 }
439
440 result
441}
442
443pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
445 let config = default_config(root);
446 analyze_with_usages(&config)
447}
448
449pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
451 let user_config = fallow_config::FallowConfig::find_and_load(root)
452 .ok()
453 .flatten();
454 match user_config {
455 Some((config, _path)) => config.resolve(
456 root.to_path_buf(),
457 fallow_config::OutputFormat::Human,
458 num_cpus(),
459 false,
460 ),
461 None => fallow_config::FallowConfig {
462 schema: None,
463 extends: vec![],
464 entry: vec![],
465 ignore_patterns: vec![],
466 framework: vec![],
467 workspaces: None,
468 ignore_dependencies: vec![],
469 ignore_exports: vec![],
470 duplicates: fallow_config::DuplicatesConfig::default(),
471 rules: fallow_config::RulesConfig::default(),
472 production: false,
473 plugins: vec![],
474 overrides: vec![],
475 }
476 .resolve(
477 root.to_path_buf(),
478 fallow_config::OutputFormat::Human,
479 num_cpus(),
480 false,
481 ),
482 }
483}
484
485fn num_cpus() -> usize {
486 std::thread::available_parallelism()
487 .map(|n| n.get())
488 .unwrap_or(4)
489}