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;
12
13use std::path::Path;
14
15use errors::FallowError;
16use fallow_config::{PackageJson, ResolvedConfig, discover_workspaces};
17use results::AnalysisResults;
18
19/// Run the full analysis pipeline.
20pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
21    let _span = tracing::info_span!("fallow_analyze").entered();
22
23    // Warn if node_modules is missing — resolution will be severely degraded
24    if !config.root.join("node_modules").is_dir() {
25        tracing::warn!(
26            "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
27        );
28    }
29
30    // Discover workspaces if in a monorepo
31    let workspaces = discover_workspaces(&config.root);
32    if !workspaces.is_empty() {
33        tracing::info!(count = workspaces.len(), "workspaces discovered");
34    }
35
36    // Stage 1: Discover all source files
37    // The root walk already discovers files in nested workspace directories,
38    // so no separate workspace walk is needed.
39    let files = discover::discover_files(config);
40
41    // Stage 1.5: Run plugin system — parse config files, discover dynamic entries
42    let plugin_result = run_plugins(config, &files, &workspaces);
43
44    // Stage 2: Parse all files in parallel and extract imports/exports
45    // Load cache if available
46    let mut cache_store = if config.no_cache {
47        None
48    } else {
49        cache::CacheStore::load(&config.cache_dir)
50    };
51
52    let modules = extract::parse_all_files(&files, config, cache_store.as_ref());
53
54    // Update cache with parsed results
55    if !config.no_cache {
56        let store = cache_store.get_or_insert_with(cache::CacheStore::new);
57        for module in &modules {
58            if let Some(file) = files.get(module.file_id.0 as usize) {
59                store.insert(&file.path, cache::module_to_cached(module));
60            }
61        }
62        if let Err(e) = store.save(&config.cache_dir) {
63            tracing::warn!("Failed to save cache: {e}");
64        }
65    }
66
67    // Stage 3: Discover entry points (static patterns + plugin-discovered patterns)
68    let mut entry_points = discover::discover_entry_points(config, &files);
69    // Also discover workspace entry points
70    for ws in &workspaces {
71        let ws_entries = discover::discover_workspace_entry_points(&ws.root, config, &files);
72        entry_points.extend(ws_entries);
73    }
74
75    // Add plugin-discovered entry points and setup files
76    let plugin_entries = discover::discover_plugin_entry_points(&plugin_result, config, &files);
77    entry_points.extend(plugin_entries);
78
79    // Stage 4: Resolve imports to file IDs
80    let resolved = resolve::resolve_all_imports(&modules, config, &files);
81
82    // Stage 5: Build module graph
83    let graph = graph::ModuleGraph::build(&resolved, &entry_points, &files);
84
85    // Stage 6: Analyze for dead code (with plugin context and workspace info)
86    Ok(analyze::find_dead_code_full(
87        &graph,
88        config,
89        &resolved,
90        Some(&plugin_result),
91        &workspaces,
92    ))
93}
94
95/// Run plugins for root project and all workspace packages.
96fn run_plugins(
97    config: &ResolvedConfig,
98    files: &[discover::DiscoveredFile],
99    workspaces: &[fallow_config::WorkspaceInfo],
100) -> plugins::AggregatedPluginResult {
101    let registry = plugins::PluginRegistry::new();
102    let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
103
104    // Run plugins for root project
105    let pkg_path = config.root.join("package.json");
106    let mut result = if let Ok(pkg) = PackageJson::load(&pkg_path) {
107        registry.run(&pkg, &config.root, &file_paths)
108    } else {
109        plugins::AggregatedPluginResult::default()
110    };
111
112    // Run plugins for each workspace package too
113    for ws in workspaces {
114        let ws_pkg_path = ws.root.join("package.json");
115        if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path) {
116            let ws_result = registry.run(&ws_pkg, &ws.root, &file_paths);
117
118            // Workspace plugin patterns are relative to the workspace root (e.g., `jest.setup.ts`),
119            // but `discover_plugin_entry_points` matches against paths relative to the monorepo root
120            // (e.g., `packages/foo/jest.setup.ts`). Prefix workspace patterns with the workspace
121            // path to make them matchable from the monorepo root.
122            let ws_prefix = ws
123                .root
124                .strip_prefix(&config.root)
125                .unwrap_or(&ws.root)
126                .to_string_lossy();
127
128            for pat in &ws_result.entry_patterns {
129                result.entry_patterns.push(format!("{ws_prefix}/{pat}"));
130            }
131            for pat in &ws_result.always_used {
132                result.always_used.push(format!("{ws_prefix}/{pat}"));
133            }
134            for pat in &ws_result.discovered_always_used {
135                result
136                    .discovered_always_used
137                    .push(format!("{ws_prefix}/{pat}"));
138            }
139            for (file_pat, exports) in &ws_result.used_exports {
140                result
141                    .used_exports
142                    .push((format!("{ws_prefix}/{file_pat}"), exports.clone()));
143            }
144            // These don't need prefixing (absolute paths / package names)
145            result
146                .referenced_dependencies
147                .extend(ws_result.referenced_dependencies);
148            result.setup_files.extend(ws_result.setup_files);
149            result
150                .tooling_dependencies
151                .extend(ws_result.tooling_dependencies);
152        }
153    }
154
155    result
156}
157
158/// Run analysis on a project directory.
159pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
160    let config = default_config(root);
161    analyze(&config)
162}
163
164/// Create a default config for a project root.
165pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
166    let user_config = fallow_config::FallowConfig::find_and_load(root)
167        .ok()
168        .flatten();
169    match user_config {
170        Some((config, _path)) => config.resolve(root.to_path_buf(), num_cpus(), false),
171        None => fallow_config::FallowConfig {
172            entry: vec![],
173            ignore: vec![],
174            detect: fallow_config::DetectConfig::default(),
175            framework: vec![],
176            workspaces: None,
177            ignore_dependencies: vec![],
178            ignore_exports: vec![],
179            output: fallow_config::OutputFormat::Human,
180            duplicates: fallow_config::DuplicatesConfig::default(),
181        }
182        .resolve(root.to_path_buf(), num_cpus(), false),
183    }
184}
185
186fn num_cpus() -> usize {
187    std::thread::available_parallelism()
188        .map(|n| n.get())
189        .unwrap_or(4)
190}