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