Skip to main content

fallow_core/plugins/
mod.rs

1//! Plugin system for framework-aware codebase 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 const 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        self.is_enabled_with_deps(&deps, root)
54    }
55
56    /// Fast variant of `is_enabled` that accepts a pre-computed deps list.
57    /// Avoids repeated `all_dependency_names()` allocation when checking many plugins.
58    fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
59        let enablers = self.enablers();
60        if enablers.is_empty() {
61            return false;
62        }
63        enablers.iter().any(|enabler| {
64            if enabler.ends_with('/') {
65                // Prefix match (e.g., "@storybook/" matches "@storybook/react")
66                deps.iter().any(|d| d.starts_with(enabler))
67            } else {
68                deps.iter().any(|d| d == enabler)
69            }
70        })
71    }
72
73    /// Default glob patterns for entry point files.
74    fn entry_patterns(&self) -> &'static [&'static str] {
75        &[]
76    }
77
78    /// Glob patterns for config files this plugin can parse.
79    fn config_patterns(&self) -> &'static [&'static str] {
80        &[]
81    }
82
83    /// Files that are always considered "used" when this plugin is active.
84    fn always_used(&self) -> &'static [&'static str] {
85        &[]
86    }
87
88    /// Exports that are always considered used for matching file patterns.
89    fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
90        vec![]
91    }
92
93    /// Dependencies that are tooling (used via CLI/config, not source imports).
94    /// These should not be flagged as unused devDependencies.
95    fn tooling_dependencies(&self) -> &'static [&'static str] {
96        &[]
97    }
98
99    /// Import prefixes that are virtual modules provided by this framework at build time.
100    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
101    /// Each entry is matched as a prefix against the extracted package name
102    /// (e.g., `"@theme/"` matches `@theme/Layout`).
103    fn virtual_module_prefixes(&self) -> &'static [&'static str] {
104        &[]
105    }
106
107    /// Path alias mappings provided by this framework at build time.
108    ///
109    /// Returns a list of `(prefix, replacement_dir)` tuples. When an import starting
110    /// with `prefix` fails to resolve, the resolver will substitute the prefix with
111    /// `replacement_dir` (relative to the project root) and retry.
112    ///
113    /// Called once when plugins are activated. The project `root` is provided so
114    /// plugins can inspect the filesystem (e.g., Nuxt checks whether `app/` exists
115    /// to determine the `srcDir`).
116    fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
117        vec![]
118    }
119
120    /// Parse a config file's AST to discover additional entries, dependencies, etc.
121    ///
122    /// Called for each config file matching `config_patterns()`. The source code
123    /// and parsed AST are provided — use [`config_parser`] utilities to extract values.
124    fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
125        PluginResult::default()
126    }
127
128    /// The key name in package.json that holds inline configuration for this tool.
129    /// When set (e.g., `"jest"` for the `"jest"` key in package.json), the plugin
130    /// system will extract that key's value and call `resolve_config` with its
131    /// JSON content if no standalone config file was found.
132    fn package_json_config_key(&self) -> Option<&'static str> {
133        None
134    }
135}
136
137/// Macro to eliminate boilerplate in plugin implementations.
138///
139/// Generates a struct and a `Plugin` trait impl with the standard static methods
140/// (`name`, `enablers`, `entry_patterns`, `config_patterns`, `always_used`, `tooling_dependencies`,
141/// `used_exports`).
142///
143/// For plugins that need custom `resolve_config()` or `is_enabled()`, keep those as
144/// manual `impl Plugin for ...` blocks instead of using this macro.
145///
146/// # Usage
147///
148/// ```ignore
149/// // Simple plugin (most common):
150/// define_plugin! {
151///     struct VitePlugin => "vite",
152///     enablers: ENABLERS,
153///     entry_patterns: ENTRY_PATTERNS,
154///     config_patterns: CONFIG_PATTERNS,
155///     always_used: ALWAYS_USED,
156///     tooling_dependencies: TOOLING_DEPENDENCIES,
157/// }
158///
159/// // Plugin with used_exports:
160/// define_plugin! {
161///     struct RemixPlugin => "remix",
162///     enablers: ENABLERS,
163///     entry_patterns: ENTRY_PATTERNS,
164///     always_used: ALWAYS_USED,
165///     tooling_dependencies: TOOLING_DEPENDENCIES,
166///     used_exports: [("app/routes/**/*.{ts,tsx}", ROUTE_EXPORTS)],
167/// }
168/// ```
169///
170/// All fields except `struct` and `enablers` are optional and default to `&[]` / `vec![]`.
171macro_rules! define_plugin {
172    (
173        struct $name:ident => $display:expr,
174        enablers: $enablers:expr
175        $(, entry_patterns: $entry:expr)?
176        $(, config_patterns: $config:expr)?
177        $(, always_used: $always:expr)?
178        $(, tooling_dependencies: $tooling:expr)?
179        $(, virtual_module_prefixes: $virtual:expr)?
180        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
181        $(,)?
182    ) => {
183        pub struct $name;
184
185        impl Plugin for $name {
186            fn name(&self) -> &'static str {
187                $display
188            }
189
190            fn enablers(&self) -> &'static [&'static str] {
191                $enablers
192            }
193
194            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
195            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
196            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
197            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
198            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
199
200            $(
201                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
202                    vec![$( ($pat, $exports) ),*]
203                }
204            )?
205        }
206    };
207}
208
209pub mod config_parser;
210pub mod registry;
211mod tooling;
212
213pub use registry::{AggregatedPluginResult, PluginRegistry};
214pub use tooling::is_known_tooling_dependency;
215
216mod angular;
217mod astro;
218mod ava;
219mod babel;
220mod biome;
221mod bun;
222mod c8;
223mod capacitor;
224mod changesets;
225mod commitizen;
226mod commitlint;
227mod cspell;
228mod cucumber;
229mod cypress;
230mod dependency_cruiser;
231mod docusaurus;
232mod drizzle;
233mod electron;
234mod eslint;
235mod expo;
236mod gatsby;
237mod graphql_codegen;
238mod husky;
239mod i18next;
240mod jest;
241mod karma;
242mod knex;
243mod kysely;
244mod lefthook;
245mod lint_staged;
246mod markdownlint;
247mod mocha;
248mod msw;
249mod nestjs;
250mod next_intl;
251mod nextjs;
252mod nitro;
253mod nodemon;
254mod nuxt;
255mod nx;
256mod nyc;
257mod openapi_ts;
258mod oxlint;
259mod parcel;
260mod playwright;
261mod plop;
262mod pm2;
263mod postcss;
264mod prettier;
265mod prisma;
266mod react_native;
267mod react_router;
268mod relay;
269mod remark;
270mod remix;
271mod rolldown;
272mod rollup;
273mod rsbuild;
274mod rspack;
275mod sanity;
276mod semantic_release;
277mod sentry;
278mod simple_git_hooks;
279mod storybook;
280mod stylelint;
281mod sveltekit;
282mod svgo;
283mod svgr;
284mod swc;
285mod syncpack;
286mod tailwind;
287mod tanstack_router;
288mod tsdown;
289mod tsup;
290mod turborepo;
291mod typedoc;
292mod typeorm;
293mod typescript;
294mod vite;
295mod vitepress;
296mod vitest;
297mod webdriverio;
298mod webpack;
299mod wrangler;
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use std::path::Path;
305
306    // ── is_enabled_with_deps edge cases ──────────────────────────
307
308    #[test]
309    fn is_enabled_with_deps_exact_match() {
310        let plugin = nextjs::NextJsPlugin;
311        let deps = vec!["next".to_string()];
312        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
313    }
314
315    #[test]
316    fn is_enabled_with_deps_no_match() {
317        let plugin = nextjs::NextJsPlugin;
318        let deps = vec!["react".to_string()];
319        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
320    }
321
322    #[test]
323    fn is_enabled_with_deps_empty_deps() {
324        let plugin = nextjs::NextJsPlugin;
325        let deps: Vec<String> = vec![];
326        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
327    }
328
329    // ── PluginResult::is_empty ───────────────────────────────────
330
331    #[test]
332    fn plugin_result_is_empty_when_default() {
333        let r = PluginResult::default();
334        assert!(r.is_empty());
335    }
336
337    #[test]
338    fn plugin_result_not_empty_with_entry_patterns() {
339        let r = PluginResult {
340            entry_patterns: vec!["*.ts".to_string()],
341            ..Default::default()
342        };
343        assert!(!r.is_empty());
344    }
345
346    #[test]
347    fn plugin_result_not_empty_with_referenced_deps() {
348        let r = PluginResult {
349            referenced_dependencies: vec!["lodash".to_string()],
350            ..Default::default()
351        };
352        assert!(!r.is_empty());
353    }
354
355    #[test]
356    fn plugin_result_not_empty_with_setup_files() {
357        let r = PluginResult {
358            setup_files: vec![PathBuf::from("/setup.ts")],
359            ..Default::default()
360        };
361        assert!(!r.is_empty());
362    }
363}