Skip to main content

fallow_core/plugins/
mod.rs

1//! Plugin system for framework-aware dead code analysis.
2//!
3//! Unlike knip's JavaScript plugin system that evaluates config files at runtime,
4//! fallow's plugin system uses Oxc's parser to extract configuration values from
5//! JS/TS/JSON config files via AST walking — no JavaScript evaluation needed.
6//!
7//! Each plugin implements the [`Plugin`] trait with:
8//! - **Static defaults**: Entry patterns, config file patterns, used exports
9//! - **Dynamic resolution**: Parse tool config files to discover additional entries,
10//!   referenced dependencies, and setup files
11
12pub mod config_parser;
13
14mod angular;
15mod astro;
16mod ava;
17mod babel;
18mod biome;
19mod changesets;
20mod commitlint;
21mod cypress;
22mod docusaurus;
23mod drizzle;
24mod eslint;
25mod expo;
26mod graphql_codegen;
27mod jest;
28mod knex;
29mod mocha;
30mod msw;
31mod nestjs;
32mod nextjs;
33mod nuxt;
34mod nx;
35mod playwright;
36mod postcss;
37mod prisma;
38mod react_native;
39mod react_router;
40mod remix;
41mod rollup;
42mod semantic_release;
43mod sentry;
44mod storybook;
45mod stylelint;
46mod tailwind;
47mod tsup;
48mod turborepo;
49mod typescript;
50mod vite;
51mod vitest;
52mod webpack;
53mod wrangler;
54
55use std::path::{Path, PathBuf};
56
57use fallow_config::PackageJson;
58
59/// Result of resolving a plugin's config file.
60#[derive(Debug, Default)]
61pub struct PluginResult {
62    /// Additional entry point glob patterns discovered from config.
63    pub entry_patterns: Vec<String>,
64    /// Dependencies referenced in config files (should not be flagged as unused).
65    pub referenced_dependencies: Vec<String>,
66    /// Additional files that are always considered used.
67    pub always_used_files: Vec<String>,
68    /// Setup/helper files referenced from config.
69    pub setup_files: Vec<PathBuf>,
70}
71
72impl PluginResult {
73    pub fn is_empty(&self) -> bool {
74        self.entry_patterns.is_empty()
75            && self.referenced_dependencies.is_empty()
76            && self.always_used_files.is_empty()
77            && self.setup_files.is_empty()
78    }
79}
80
81/// A framework/tool plugin that contributes to dead code analysis.
82pub trait Plugin: Send + Sync {
83    /// Human-readable plugin name.
84    fn name(&self) -> &'static str;
85
86    /// Package names that activate this plugin when found in package.json.
87    /// Supports exact matches and prefix patterns (ending with `/`).
88    fn enablers(&self) -> &'static [&'static str] {
89        &[]
90    }
91
92    /// Check if this plugin should be active for the given project.
93    /// Default implementation checks `enablers()` against package.json dependencies.
94    fn is_enabled(&self, pkg: &PackageJson, _root: &Path) -> bool {
95        let deps = pkg.all_dependency_names();
96        let enablers = self.enablers();
97        if enablers.is_empty() {
98            return false;
99        }
100        enablers.iter().any(|enabler| {
101            if enabler.ends_with('/') {
102                // Prefix match (e.g., "@storybook/" matches "@storybook/react")
103                deps.iter().any(|d| d.starts_with(enabler))
104            } else {
105                deps.iter().any(|d| d == enabler)
106            }
107        })
108    }
109
110    /// Default glob patterns for entry point files.
111    fn entry_patterns(&self) -> &'static [&'static str] {
112        &[]
113    }
114
115    /// Glob patterns for config files this plugin can parse.
116    fn config_patterns(&self) -> &'static [&'static str] {
117        &[]
118    }
119
120    /// Files that are always considered "used" when this plugin is active.
121    fn always_used(&self) -> &'static [&'static str] {
122        &[]
123    }
124
125    /// Exports that are always considered used for matching file patterns.
126    fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
127        vec![]
128    }
129
130    /// Dependencies that are tooling (used via CLI/config, not source imports).
131    /// These should not be flagged as unused devDependencies.
132    fn tooling_dependencies(&self) -> &'static [&'static str] {
133        &[]
134    }
135
136    /// Parse a config file's AST to discover additional entries, dependencies, etc.
137    ///
138    /// Called for each config file matching `config_patterns()`. The source code
139    /// and parsed AST are provided — use [`config_parser`] utilities to extract values.
140    fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
141        PluginResult::default()
142    }
143}
144
145/// Registry of all available plugins.
146pub struct PluginRegistry {
147    plugins: Vec<Box<dyn Plugin>>,
148}
149
150/// Aggregated results from all active plugins for a project.
151#[derive(Debug, Default)]
152pub struct AggregatedPluginResult {
153    /// All entry point patterns from active plugins.
154    pub entry_patterns: Vec<String>,
155    /// All config file patterns from active plugins.
156    pub config_patterns: Vec<String>,
157    /// All always-used file patterns from active plugins.
158    pub always_used: Vec<String>,
159    /// All used export rules from active plugins.
160    pub used_exports: Vec<(String, Vec<String>)>,
161    /// Dependencies referenced in config files (should not be flagged unused).
162    pub referenced_dependencies: Vec<String>,
163    /// Additional always-used files discovered from config parsing.
164    pub discovered_always_used: Vec<String>,
165    /// Setup files discovered from config parsing.
166    pub setup_files: Vec<PathBuf>,
167    /// Tooling dependencies (should not be flagged as unused devDeps).
168    pub tooling_dependencies: Vec<String>,
169    /// Names of active plugins.
170    pub active_plugins: Vec<String>,
171}
172
173impl PluginRegistry {
174    /// Create a registry with all built-in plugins.
175    pub fn new() -> Self {
176        let plugins: Vec<Box<dyn Plugin>> = vec![
177            // Frameworks
178            Box::new(nextjs::NextJsPlugin),
179            Box::new(nuxt::NuxtPlugin),
180            Box::new(remix::RemixPlugin),
181            Box::new(astro::AstroPlugin),
182            Box::new(angular::AngularPlugin),
183            Box::new(react_router::ReactRouterPlugin),
184            Box::new(react_native::ReactNativePlugin),
185            Box::new(expo::ExpoPlugin),
186            Box::new(nestjs::NestJsPlugin),
187            Box::new(docusaurus::DocusaurusPlugin),
188            // Bundlers
189            Box::new(vite::VitePlugin),
190            Box::new(webpack::WebpackPlugin),
191            Box::new(rollup::RollupPlugin),
192            Box::new(tsup::TsupPlugin),
193            // Testing
194            Box::new(vitest::VitestPlugin),
195            Box::new(jest::JestPlugin),
196            Box::new(playwright::PlaywrightPlugin),
197            Box::new(cypress::CypressPlugin),
198            Box::new(mocha::MochaPlugin),
199            Box::new(ava::AvaPlugin),
200            Box::new(storybook::StorybookPlugin),
201            // Linting & formatting
202            Box::new(eslint::EslintPlugin),
203            Box::new(biome::BiomePlugin),
204            Box::new(stylelint::StylelintPlugin),
205            // Transpilation & language
206            Box::new(typescript::TypeScriptPlugin),
207            Box::new(babel::BabelPlugin),
208            // CSS
209            Box::new(tailwind::TailwindPlugin),
210            Box::new(postcss::PostCssPlugin),
211            // Database & ORM
212            Box::new(prisma::PrismaPlugin),
213            Box::new(drizzle::DrizzlePlugin),
214            Box::new(knex::KnexPlugin),
215            // Monorepo
216            Box::new(turborepo::TurborepoPlugin),
217            Box::new(nx::NxPlugin),
218            Box::new(changesets::ChangesetsPlugin),
219            // CI/CD & release
220            Box::new(commitlint::CommitlintPlugin),
221            Box::new(semantic_release::SemanticReleasePlugin),
222            // Deployment
223            Box::new(wrangler::WranglerPlugin),
224            Box::new(sentry::SentryPlugin),
225            // Other tools
226            Box::new(graphql_codegen::GraphqlCodegenPlugin),
227            Box::new(msw::MswPlugin),
228        ];
229        Self { plugins }
230    }
231
232    /// Run all plugins against a project, returning aggregated results.
233    ///
234    /// This discovers which plugins are active, collects their static patterns,
235    /// then parses any config files to extract dynamic information.
236    pub fn run(
237        &self,
238        pkg: &PackageJson,
239        root: &Path,
240        discovered_files: &[PathBuf],
241    ) -> AggregatedPluginResult {
242        let _span = tracing::info_span!("run_plugins").entered();
243        let mut result = AggregatedPluginResult::default();
244
245        // Phase 1: Determine which plugins are active
246        let active: Vec<&dyn Plugin> = self
247            .plugins
248            .iter()
249            .filter(|p| p.is_enabled(pkg, root))
250            .map(|p| p.as_ref())
251            .collect();
252
253        tracing::info!(
254            plugins = active
255                .iter()
256                .map(|p| p.name())
257                .collect::<Vec<_>>()
258                .join(", "),
259            "active plugins"
260        );
261
262        // Phase 2: Collect static patterns from active plugins
263        for plugin in &active {
264            result.active_plugins.push(plugin.name().to_string());
265
266            for pat in plugin.entry_patterns() {
267                result.entry_patterns.push((*pat).to_string());
268            }
269            for pat in plugin.config_patterns() {
270                result.config_patterns.push((*pat).to_string());
271            }
272            for pat in plugin.always_used() {
273                result.always_used.push((*pat).to_string());
274            }
275            for (file_pat, exports) in plugin.used_exports() {
276                result.used_exports.push((
277                    file_pat.to_string(),
278                    exports.iter().map(|s| s.to_string()).collect(),
279                ));
280            }
281            for dep in plugin.tooling_dependencies() {
282                result.tooling_dependencies.push((*dep).to_string());
283            }
284        }
285
286        // Phase 3: Find and parse config files for dynamic resolution
287        // Pre-compile all config patterns
288        let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
289            .iter()
290            .filter(|p| !p.config_patterns().is_empty())
291            .map(|p| {
292                let matchers: Vec<globset::GlobMatcher> = p
293                    .config_patterns()
294                    .iter()
295                    .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
296                    .collect();
297                (*p, matchers)
298            })
299            .collect();
300
301        if !config_matchers.is_empty() {
302            // Build relative paths for matching
303            let relative_files: Vec<(&PathBuf, String)> = discovered_files
304                .iter()
305                .map(|f| {
306                    let rel = f
307                        .strip_prefix(root)
308                        .unwrap_or(f)
309                        .to_string_lossy()
310                        .into_owned();
311                    (f, rel)
312                })
313                .collect();
314
315            for (plugin, matchers) in &config_matchers {
316                for (abs_path, rel_path) in &relative_files {
317                    if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
318                        // Found a config file — parse it
319                        if let Ok(source) = std::fs::read_to_string(abs_path) {
320                            let plugin_result = plugin.resolve_config(abs_path, &source, root);
321                            if !plugin_result.is_empty() {
322                                tracing::debug!(
323                                    plugin = plugin.name(),
324                                    config = rel_path.as_str(),
325                                    entries = plugin_result.entry_patterns.len(),
326                                    deps = plugin_result.referenced_dependencies.len(),
327                                    "resolved config"
328                                );
329                                result.entry_patterns.extend(plugin_result.entry_patterns);
330                                result
331                                    .referenced_dependencies
332                                    .extend(plugin_result.referenced_dependencies);
333                                result
334                                    .discovered_always_used
335                                    .extend(plugin_result.always_used_files);
336                                result.setup_files.extend(plugin_result.setup_files);
337                            }
338                        }
339                    }
340                }
341            }
342        }
343
344        result
345    }
346}
347
348impl Default for PluginRegistry {
349    fn default() -> Self {
350        Self::new()
351    }
352}