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/// // Plugin with imports-only resolve_config (extracts imports from config as deps):
170/// define_plugin! {
171///     struct CypressPlugin => "cypress",
172///     enablers: ENABLERS,
173///     entry_patterns: ENTRY_PATTERNS,
174///     config_patterns: CONFIG_PATTERNS,
175///     always_used: ALWAYS_USED,
176///     tooling_dependencies: TOOLING_DEPENDENCIES,
177///     resolve_config: imports_only,
178/// }
179/// ```
180///
181/// All fields except `struct` and `enablers` are optional and default to `&[]` / `vec![]`.
182macro_rules! define_plugin {
183    // Variant with `resolve_config: imports_only` — generates a resolve_config method
184    // that extracts imports from config files and registers them as referenced dependencies.
185    (
186        struct $name:ident => $display:expr,
187        enablers: $enablers:expr
188        $(, entry_patterns: $entry:expr)?
189        $(, config_patterns: $config:expr)?
190        $(, always_used: $always:expr)?
191        $(, tooling_dependencies: $tooling:expr)?
192        $(, virtual_module_prefixes: $virtual:expr)?
193        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
194        , resolve_config: imports_only
195        $(,)?
196    ) => {
197        pub struct $name;
198
199        impl Plugin for $name {
200            fn name(&self) -> &'static str {
201                $display
202            }
203
204            fn enablers(&self) -> &'static [&'static str] {
205                $enablers
206            }
207
208            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
209            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
210            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
211            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
212            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
213
214            $(
215                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
216                    vec![$( ($pat, $exports) ),*]
217                }
218            )?
219
220            fn resolve_config(
221                &self,
222                config_path: &std::path::Path,
223                source: &str,
224                _root: &std::path::Path,
225            ) -> PluginResult {
226                let mut result = PluginResult::default();
227                let imports = crate::plugins::config_parser::extract_imports(source, config_path);
228                for imp in &imports {
229                    let dep = crate::resolve::extract_package_name(imp);
230                    result.referenced_dependencies.push(dep);
231                }
232                result
233            }
234        }
235    };
236
237    // Base variant — no resolve_config.
238    (
239        struct $name:ident => $display:expr,
240        enablers: $enablers:expr
241        $(, entry_patterns: $entry:expr)?
242        $(, config_patterns: $config:expr)?
243        $(, always_used: $always:expr)?
244        $(, tooling_dependencies: $tooling:expr)?
245        $(, virtual_module_prefixes: $virtual:expr)?
246        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
247        $(,)?
248    ) => {
249        pub struct $name;
250
251        impl Plugin for $name {
252            fn name(&self) -> &'static str {
253                $display
254            }
255
256            fn enablers(&self) -> &'static [&'static str] {
257                $enablers
258            }
259
260            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
261            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
262            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
263            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
264            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
265
266            $(
267                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
268                    vec![$( ($pat, $exports) ),*]
269                }
270            )?
271        }
272    };
273}
274
275pub mod config_parser;
276pub mod registry;
277mod tooling;
278
279pub use registry::{AggregatedPluginResult, PluginRegistry};
280pub use tooling::is_known_tooling_dependency;
281
282mod angular;
283mod astro;
284mod ava;
285mod babel;
286mod biome;
287mod bun;
288mod c8;
289mod capacitor;
290mod changesets;
291mod commitizen;
292mod commitlint;
293mod cspell;
294mod cucumber;
295mod cypress;
296mod dependency_cruiser;
297mod docusaurus;
298mod drizzle;
299mod electron;
300mod eslint;
301mod expo;
302mod gatsby;
303mod graphql_codegen;
304mod husky;
305mod i18next;
306mod jest;
307mod karma;
308mod knex;
309mod kysely;
310mod lefthook;
311mod lint_staged;
312mod markdownlint;
313mod mocha;
314mod msw;
315mod nestjs;
316mod next_intl;
317mod nextjs;
318mod nitro;
319mod nodemon;
320mod nuxt;
321mod nx;
322mod nyc;
323mod openapi_ts;
324mod oxlint;
325mod parcel;
326mod playwright;
327mod plop;
328mod pm2;
329mod postcss;
330mod prettier;
331mod prisma;
332mod react_native;
333mod react_router;
334mod relay;
335mod remark;
336mod remix;
337mod rolldown;
338mod rollup;
339mod rsbuild;
340mod rspack;
341mod sanity;
342mod semantic_release;
343mod sentry;
344mod simple_git_hooks;
345mod storybook;
346mod stylelint;
347mod sveltekit;
348mod svgo;
349mod svgr;
350mod swc;
351mod syncpack;
352mod tailwind;
353mod tanstack_router;
354mod tsdown;
355mod tsup;
356mod turborepo;
357mod typedoc;
358mod typeorm;
359mod typescript;
360mod vite;
361mod vitepress;
362mod vitest;
363mod webdriverio;
364mod webpack;
365mod wrangler;
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use std::path::Path;
371
372    // ── is_enabled_with_deps edge cases ──────────────────────────
373
374    #[test]
375    fn is_enabled_with_deps_exact_match() {
376        let plugin = nextjs::NextJsPlugin;
377        let deps = vec!["next".to_string()];
378        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
379    }
380
381    #[test]
382    fn is_enabled_with_deps_no_match() {
383        let plugin = nextjs::NextJsPlugin;
384        let deps = vec!["react".to_string()];
385        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
386    }
387
388    #[test]
389    fn is_enabled_with_deps_empty_deps() {
390        let plugin = nextjs::NextJsPlugin;
391        let deps: Vec<String> = vec![];
392        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
393    }
394
395    // ── PluginResult::is_empty ───────────────────────────────────
396
397    #[test]
398    fn plugin_result_is_empty_when_default() {
399        let r = PluginResult::default();
400        assert!(r.is_empty());
401    }
402
403    #[test]
404    fn plugin_result_not_empty_with_entry_patterns() {
405        let r = PluginResult {
406            entry_patterns: vec!["*.ts".to_string()],
407            ..Default::default()
408        };
409        assert!(!r.is_empty());
410    }
411
412    #[test]
413    fn plugin_result_not_empty_with_referenced_deps() {
414        let r = PluginResult {
415            referenced_dependencies: vec!["lodash".to_string()],
416            ..Default::default()
417        };
418        assert!(!r.is_empty());
419    }
420
421    #[test]
422    fn plugin_result_not_empty_with_setup_files() {
423        let r = PluginResult {
424            setup_files: vec![PathBuf::from("/setup.ts")],
425            ..Default::default()
426        };
427        assert!(!r.is_empty());
428    }
429}