use std::path::{Path, PathBuf};
use fallow_config::PackageJson;
#[derive(Debug, Default)]
pub struct PluginResult {
pub entry_patterns: Vec<String>,
pub referenced_dependencies: Vec<String>,
pub always_used_files: Vec<String>,
pub setup_files: Vec<PathBuf>,
pub fixture_patterns: Vec<String>,
}
impl PluginResult {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.entry_patterns.is_empty()
&& self.referenced_dependencies.is_empty()
&& self.always_used_files.is_empty()
&& self.setup_files.is_empty()
&& self.fixture_patterns.is_empty()
}
}
pub trait Plugin: Send + Sync {
fn name(&self) -> &'static str;
fn enablers(&self) -> &'static [&'static str] {
&[]
}
fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
let deps = pkg.all_dependency_names();
self.is_enabled_with_deps(&deps, root)
}
fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
let enablers = self.enablers();
if enablers.is_empty() {
return false;
}
enablers.iter().any(|enabler| {
if enabler.ends_with('/') {
deps.iter().any(|d| d.starts_with(enabler))
} else {
deps.iter().any(|d| d == enabler)
}
})
}
fn entry_patterns(&self) -> &'static [&'static str] {
&[]
}
fn config_patterns(&self) -> &'static [&'static str] {
&[]
}
fn always_used(&self) -> &'static [&'static str] {
&[]
}
fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
vec![]
}
fn fixture_glob_patterns(&self) -> &'static [&'static str] {
&[]
}
fn tooling_dependencies(&self) -> &'static [&'static str] {
&[]
}
fn virtual_module_prefixes(&self) -> &'static [&'static str] {
&[]
}
fn generated_import_patterns(&self) -> &'static [&'static str] {
&[]
}
fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
vec![]
}
fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
PluginResult::default()
}
fn package_json_config_key(&self) -> Option<&'static str> {
None
}
}
macro_rules! define_plugin {
(
struct $name:ident => $display:expr,
enablers: $enablers:expr
$(, entry_patterns: $entry:expr)?
$(, config_patterns: $config:expr)?
$(, always_used: $always:expr)?
$(, tooling_dependencies: $tooling:expr)?
$(, fixture_glob_patterns: $fixtures:expr)?
$(, virtual_module_prefixes: $virtual:expr)?
$(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
, resolve_config: imports_only
$(,)?
) => {
pub struct $name;
impl Plugin for $name {
fn name(&self) -> &'static str {
$display
}
fn enablers(&self) -> &'static [&'static str] {
$enablers
}
$( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
$( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
$( fn always_used(&self) -> &'static [&'static str] { $always } )?
$( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
$( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
$( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
$(
fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
vec![$( ($pat, $exports) ),*]
}
)?
fn resolve_config(
&self,
config_path: &std::path::Path,
source: &str,
_root: &std::path::Path,
) -> PluginResult {
let mut result = PluginResult::default();
let imports = crate::plugins::config_parser::extract_imports(source, config_path);
for imp in &imports {
let dep = crate::resolve::extract_package_name(imp);
result.referenced_dependencies.push(dep);
}
result
}
}
};
(
struct $name:ident => $display:expr,
enablers: $enablers:expr
$(, entry_patterns: $entry:expr)?
$(, config_patterns: $config:expr)?
$(, always_used: $always:expr)?
$(, tooling_dependencies: $tooling:expr)?
$(, fixture_glob_patterns: $fixtures:expr)?
$(, virtual_module_prefixes: $virtual:expr)?
$(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
$(,)?
) => {
pub struct $name;
impl Plugin for $name {
fn name(&self) -> &'static str {
$display
}
fn enablers(&self) -> &'static [&'static str] {
$enablers
}
$( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
$( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
$( fn always_used(&self) -> &'static [&'static str] { $always } )?
$( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
$( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
$( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
$(
fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
vec![$( ($pat, $exports) ),*]
}
)?
}
};
}
pub mod config_parser;
pub mod registry;
mod tooling;
pub use registry::{AggregatedPluginResult, PluginRegistry};
pub use tooling::is_known_tooling_dependency;
mod angular;
mod astro;
mod ava;
mod babel;
mod biome;
mod bun;
mod c8;
mod capacitor;
mod changesets;
mod commitizen;
mod commitlint;
mod cspell;
mod cucumber;
mod cypress;
mod dependency_cruiser;
mod docusaurus;
mod drizzle;
mod electron;
mod eslint;
mod expo;
mod gatsby;
mod graphql_codegen;
mod husky;
mod i18next;
mod jest;
mod karma;
mod knex;
mod kysely;
mod lefthook;
mod lint_staged;
mod markdownlint;
mod mocha;
mod msw;
mod nestjs;
mod next_intl;
mod nextjs;
mod nitro;
mod nodemon;
mod nuxt;
mod nx;
mod nyc;
mod openapi_ts;
mod oxlint;
mod parcel;
mod playwright;
mod plop;
mod pm2;
mod postcss;
mod prettier;
mod prisma;
mod react_native;
mod react_router;
mod relay;
mod remark;
mod remix;
mod rolldown;
mod rollup;
mod rsbuild;
mod rspack;
mod sanity;
mod semantic_release;
mod sentry;
mod simple_git_hooks;
mod storybook;
mod stylelint;
mod sveltekit;
mod svgo;
mod svgr;
mod swc;
mod syncpack;
mod tailwind;
mod tanstack_router;
mod tsdown;
mod tsup;
mod turborepo;
mod typedoc;
mod typeorm;
mod typescript;
mod vite;
mod vitepress;
mod vitest;
mod webdriverio;
mod webpack;
mod wrangler;
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn is_enabled_with_deps_exact_match() {
let plugin = nextjs::NextJsPlugin;
let deps = vec!["next".to_string()];
assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
}
#[test]
fn is_enabled_with_deps_no_match() {
let plugin = nextjs::NextJsPlugin;
let deps = vec!["react".to_string()];
assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
}
#[test]
fn is_enabled_with_deps_empty_deps() {
let plugin = nextjs::NextJsPlugin;
let deps: Vec<String> = vec![];
assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
}
#[test]
fn plugin_result_is_empty_when_default() {
let r = PluginResult::default();
assert!(r.is_empty());
}
#[test]
fn plugin_result_not_empty_with_entry_patterns() {
let r = PluginResult {
entry_patterns: vec!["*.ts".to_string()],
..Default::default()
};
assert!(!r.is_empty());
}
#[test]
fn plugin_result_not_empty_with_referenced_deps() {
let r = PluginResult {
referenced_dependencies: vec!["lodash".to_string()],
..Default::default()
};
assert!(!r.is_empty());
}
#[test]
fn plugin_result_not_empty_with_setup_files() {
let r = PluginResult {
setup_files: vec![PathBuf::from("/setup.ts")],
..Default::default()
};
assert!(!r.is_empty());
}
#[test]
fn plugin_result_not_empty_with_always_used_files() {
let r = PluginResult {
always_used_files: vec!["**/*.stories.tsx".to_string()],
..Default::default()
};
assert!(!r.is_empty());
}
#[test]
fn plugin_result_not_empty_with_fixture_patterns() {
let r = PluginResult {
fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
..Default::default()
};
assert!(!r.is_empty());
}
#[test]
fn is_enabled_with_deps_prefix_match() {
let plugin = storybook::StorybookPlugin;
let deps = vec!["@storybook/react".to_string()];
assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
}
#[test]
fn is_enabled_with_deps_prefix_no_match_without_slash() {
let plugin = storybook::StorybookPlugin;
let deps = vec!["@storybookish".to_string()];
assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
}
#[test]
fn is_enabled_with_deps_multiple_enablers() {
let plugin = vitest::VitestPlugin;
let deps_vitest = vec!["vitest".to_string()];
let deps_none = vec!["mocha".to_string()];
assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
}
#[test]
fn plugin_default_methods_return_empty() {
let plugin = commitizen::CommitizenPlugin;
assert!(
plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
);
assert!(plugin.virtual_module_prefixes().is_empty());
assert!(plugin.path_aliases(Path::new("/project")).is_empty());
assert!(
plugin.package_json_config_key().is_none()
|| plugin.package_json_config_key().is_some()
);
}
#[test]
fn plugin_resolve_config_default_returns_empty() {
let plugin = commitizen::CommitizenPlugin;
let result = plugin.resolve_config(
Path::new("/project/config.js"),
"const x = 1;",
Path::new("/project"),
);
assert!(result.is_empty());
}
#[test]
fn is_enabled_with_deps_exact_and_prefix_both_work() {
let plugin = storybook::StorybookPlugin;
let deps_exact = vec!["storybook".to_string()];
assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
let deps_prefix = vec!["@storybook/vue3".to_string()];
assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
}
#[test]
fn is_enabled_with_deps_multiple_enablers_remix() {
let plugin = remix::RemixPlugin;
let deps_node = vec!["@remix-run/node".to_string()];
assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
let deps_react = vec!["@remix-run/react".to_string()];
assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
let deps_cf = vec!["@remix-run/cloudflare".to_string()];
assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
}
struct MinimalPlugin;
impl Plugin for MinimalPlugin {
fn name(&self) -> &'static str {
"minimal"
}
}
#[test]
fn default_enablers_is_empty() {
assert!(MinimalPlugin.enablers().is_empty());
}
#[test]
fn default_entry_patterns_is_empty() {
assert!(MinimalPlugin.entry_patterns().is_empty());
}
#[test]
fn default_config_patterns_is_empty() {
assert!(MinimalPlugin.config_patterns().is_empty());
}
#[test]
fn default_always_used_is_empty() {
assert!(MinimalPlugin.always_used().is_empty());
}
#[test]
fn default_used_exports_is_empty() {
assert!(MinimalPlugin.used_exports().is_empty());
}
#[test]
fn default_tooling_dependencies_is_empty() {
assert!(MinimalPlugin.tooling_dependencies().is_empty());
}
#[test]
fn default_fixture_glob_patterns_is_empty() {
assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
}
#[test]
fn default_virtual_module_prefixes_is_empty() {
assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
}
#[test]
fn default_path_aliases_is_empty() {
assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
}
#[test]
fn default_resolve_config_returns_empty() {
let r = MinimalPlugin.resolve_config(
Path::new("config.js"),
"export default {}",
Path::new("/"),
);
assert!(r.is_empty());
}
#[test]
fn default_package_json_config_key_is_none() {
assert!(MinimalPlugin.package_json_config_key().is_none());
}
#[test]
fn default_is_enabled_returns_false_when_no_enablers() {
let deps = vec!["anything".to_string()];
assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
}
#[test]
fn all_builtin_plugin_names_are_unique() {
let plugins = registry::builtin::create_builtin_plugins();
let mut seen = std::collections::BTreeSet::new();
for p in &plugins {
let name = p.name();
assert!(seen.insert(name), "duplicate plugin name: {name}");
}
}
#[test]
fn all_builtin_plugins_have_enablers() {
let plugins = registry::builtin::create_builtin_plugins();
for p in &plugins {
assert!(
!p.enablers().is_empty(),
"plugin '{}' has no enablers",
p.name()
);
}
}
#[test]
fn plugins_with_config_patterns_have_always_used() {
let plugins = registry::builtin::create_builtin_plugins();
for p in &plugins {
if !p.config_patterns().is_empty() {
assert!(
!p.always_used().is_empty(),
"plugin '{}' has config_patterns but no always_used",
p.name()
);
}
}
}
#[test]
fn framework_plugins_enablers() {
let cases: Vec<(&dyn Plugin, &[&str])> = vec![
(&nextjs::NextJsPlugin, &["next"]),
(&nuxt::NuxtPlugin, &["nuxt"]),
(&angular::AngularPlugin, &["@angular/core"]),
(&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
(&gatsby::GatsbyPlugin, &["gatsby"]),
];
for (plugin, expected_enablers) in cases {
let enablers = plugin.enablers();
for expected in expected_enablers {
assert!(
enablers.contains(expected),
"plugin '{}' should have '{}'",
plugin.name(),
expected
);
}
}
}
#[test]
fn testing_plugins_enablers() {
let cases: Vec<(&dyn Plugin, &str)> = vec![
(&jest::JestPlugin, "jest"),
(&vitest::VitestPlugin, "vitest"),
(&playwright::PlaywrightPlugin, "@playwright/test"),
(&cypress::CypressPlugin, "cypress"),
(&mocha::MochaPlugin, "mocha"),
];
for (plugin, enabler) in cases {
assert!(
plugin.enablers().contains(&enabler),
"plugin '{}' should have '{}'",
plugin.name(),
enabler
);
}
}
#[test]
fn bundler_plugins_enablers() {
let cases: Vec<(&dyn Plugin, &str)> = vec![
(&vite::VitePlugin, "vite"),
(&webpack::WebpackPlugin, "webpack"),
(&rollup::RollupPlugin, "rollup"),
];
for (plugin, enabler) in cases {
assert!(
plugin.enablers().contains(&enabler),
"plugin '{}' should have '{}'",
plugin.name(),
enabler
);
}
}
#[test]
fn test_plugins_have_test_entry_patterns() {
let test_plugins: Vec<&dyn Plugin> = vec![
&jest::JestPlugin,
&vitest::VitestPlugin,
&mocha::MochaPlugin,
];
for plugin in test_plugins {
let patterns = plugin.entry_patterns();
assert!(
!patterns.is_empty(),
"test plugin '{}' should have entry patterns",
plugin.name()
);
assert!(
patterns
.iter()
.any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
"test plugin '{}' should have test/spec patterns",
plugin.name()
);
}
}
#[test]
fn framework_plugins_have_entry_patterns() {
let plugins: Vec<&dyn Plugin> = vec![
&nextjs::NextJsPlugin,
&nuxt::NuxtPlugin,
&angular::AngularPlugin,
&sveltekit::SvelteKitPlugin,
];
for plugin in plugins {
assert!(
!plugin.entry_patterns().is_empty(),
"framework plugin '{}' should have entry patterns",
plugin.name()
);
}
}
#[test]
fn plugins_with_resolve_config_have_config_patterns() {
let plugins: Vec<&dyn Plugin> = vec![
&jest::JestPlugin,
&vitest::VitestPlugin,
&babel::BabelPlugin,
&eslint::EslintPlugin,
&webpack::WebpackPlugin,
&storybook::StorybookPlugin,
&typescript::TypeScriptPlugin,
&postcss::PostCssPlugin,
&nextjs::NextJsPlugin,
&nuxt::NuxtPlugin,
&angular::AngularPlugin,
&nx::NxPlugin,
&rollup::RollupPlugin,
&sveltekit::SvelteKitPlugin,
&prettier::PrettierPlugin,
];
for plugin in plugins {
assert!(
!plugin.config_patterns().is_empty(),
"plugin '{}' with resolve_config should have config_patterns",
plugin.name()
);
}
}
#[test]
fn plugin_tooling_deps_include_enabler_package() {
let plugins: Vec<&dyn Plugin> = vec![
&jest::JestPlugin,
&vitest::VitestPlugin,
&webpack::WebpackPlugin,
&typescript::TypeScriptPlugin,
&eslint::EslintPlugin,
&prettier::PrettierPlugin,
];
for plugin in plugins {
let tooling = plugin.tooling_dependencies();
let enablers = plugin.enablers();
assert!(
enablers
.iter()
.any(|e| !e.ends_with('/') && tooling.contains(e)),
"plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
plugin.name()
);
}
}
#[test]
fn nextjs_has_used_exports_for_pages() {
let plugin = nextjs::NextJsPlugin;
let exports = plugin.used_exports();
assert!(!exports.is_empty());
assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
}
#[test]
fn remix_has_used_exports_for_routes() {
let plugin = remix::RemixPlugin;
let exports = plugin.used_exports();
assert!(!exports.is_empty());
let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
assert!(route_entry.is_some());
let (_, names) = route_entry.unwrap();
assert!(names.contains(&"loader"));
assert!(names.contains(&"action"));
assert!(names.contains(&"default"));
}
#[test]
fn sveltekit_has_used_exports_for_routes() {
let plugin = sveltekit::SvelteKitPlugin;
let exports = plugin.used_exports();
assert!(!exports.is_empty());
assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
}
#[test]
fn nuxt_has_hash_virtual_prefix() {
assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
}
#[test]
fn sveltekit_has_dollar_virtual_prefixes() {
let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
assert!(prefixes.contains(&"$app/"));
assert!(prefixes.contains(&"$env/"));
assert!(prefixes.contains(&"$lib/"));
}
#[test]
fn sveltekit_has_lib_path_alias() {
let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
}
#[test]
fn nuxt_has_tilde_path_alias() {
let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
}
#[test]
fn jest_has_package_json_config_key() {
assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
}
#[test]
fn babel_has_package_json_config_key() {
assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
}
#[test]
fn eslint_has_package_json_config_key() {
assert_eq!(
eslint::EslintPlugin.package_json_config_key(),
Some("eslintConfig")
);
}
#[test]
fn prettier_has_package_json_config_key() {
assert_eq!(
prettier::PrettierPlugin.package_json_config_key(),
Some("prettier")
);
}
#[test]
fn macro_generated_plugin_basic_properties() {
let plugin = msw::MswPlugin;
assert_eq!(plugin.name(), "msw");
assert!(plugin.enablers().contains(&"msw"));
assert!(!plugin.entry_patterns().is_empty());
assert!(plugin.config_patterns().is_empty());
assert!(!plugin.always_used().is_empty());
assert!(!plugin.tooling_dependencies().is_empty());
}
#[test]
fn macro_generated_plugin_with_used_exports() {
let plugin = remix::RemixPlugin;
assert_eq!(plugin.name(), "remix");
assert!(!plugin.used_exports().is_empty());
}
#[test]
fn macro_generated_plugin_imports_only_resolve_config() {
let plugin = cypress::CypressPlugin;
let source = r"
import { defineConfig } from 'cypress';
import coveragePlugin from '@cypress/code-coverage';
export default defineConfig({});
";
let result = plugin.resolve_config(
Path::new("cypress.config.ts"),
source,
Path::new("/project"),
);
assert!(
result
.referenced_dependencies
.contains(&"cypress".to_string())
);
assert!(
result
.referenced_dependencies
.contains(&"@cypress/code-coverage".to_string())
);
}
#[test]
fn builtin_plugin_count_is_expected() {
let plugins = registry::builtin::create_builtin_plugins();
assert!(
plugins.len() >= 80,
"expected at least 80 built-in plugins, got {}",
plugins.len()
);
}
}