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::{EntryPointRole, PackageJson};
15
16const TEST_ENTRY_POINT_PLUGINS: &[&str] = &[
17    "ava",
18    "cucumber",
19    "cypress",
20    "jest",
21    "mocha",
22    "playwright",
23    "vitest",
24    "webdriverio",
25];
26
27const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
28    "angular",
29    "astro",
30    "docusaurus",
31    "electron",
32    "expo",
33    "gatsby",
34    "nestjs",
35    "next-intl",
36    "nextjs",
37    "nitro",
38    "nuxt",
39    "parcel",
40    "react-native",
41    "react-router",
42    "remix",
43    "rolldown",
44    "rollup",
45    "rsbuild",
46    "rspack",
47    "sanity",
48    "sveltekit",
49    "tanstack-router",
50    "tsdown",
51    "tsup",
52    "vite",
53    "vitepress",
54    "webpack",
55    "wrangler",
56];
57
58#[cfg(test)]
59const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
60    "drizzle",
61    "i18next",
62    "knex",
63    "kysely",
64    "msw",
65    "prisma",
66    "storybook",
67    "typeorm",
68];
69
70/// Result of resolving a plugin's config file.
71#[derive(Debug, Default)]
72pub struct PluginResult {
73    /// Additional entry point glob patterns discovered from config.
74    pub entry_patterns: Vec<String>,
75    /// When true, `entry_patterns` from config replace the plugin's static
76    /// `entry_patterns()` defaults instead of adding to them. Tools like Vitest
77    /// and Jest treat their config's include/testMatch as a replacement for built-in
78    /// defaults, so when the config is explicit the static patterns must be dropped.
79    pub replace_entry_patterns: bool,
80    /// Additional export-usage rules discovered from config.
81    pub used_exports: Vec<(String, Vec<String>)>,
82    /// Dependencies referenced in config files (should not be flagged as unused).
83    pub referenced_dependencies: Vec<String>,
84    /// Additional files that are always considered used.
85    pub always_used_files: Vec<String>,
86    /// Path alias mappings discovered from config (prefix -> replacement directory).
87    pub path_aliases: Vec<(String, String)>,
88    /// Setup/helper files referenced from config.
89    pub setup_files: Vec<PathBuf>,
90    /// Test fixture glob patterns discovered from config.
91    pub fixture_patterns: Vec<String>,
92}
93
94impl PluginResult {
95    #[must_use]
96    pub const fn is_empty(&self) -> bool {
97        self.entry_patterns.is_empty()
98            && self.used_exports.is_empty()
99            && self.referenced_dependencies.is_empty()
100            && self.always_used_files.is_empty()
101            && self.path_aliases.is_empty()
102            && self.setup_files.is_empty()
103            && self.fixture_patterns.is_empty()
104    }
105}
106
107/// A framework/tool plugin that contributes to dead code analysis.
108pub trait Plugin: Send + Sync {
109    /// Human-readable plugin name.
110    fn name(&self) -> &'static str;
111
112    /// Package names that activate this plugin when found in package.json.
113    /// Supports exact matches and prefix patterns (ending with `/`).
114    fn enablers(&self) -> &'static [&'static str] {
115        &[]
116    }
117
118    /// Check if this plugin should be active for the given project.
119    /// Default implementation checks `enablers()` against package.json dependencies.
120    fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
121        let deps = pkg.all_dependency_names();
122        self.is_enabled_with_deps(&deps, root)
123    }
124
125    /// Fast variant of `is_enabled` that accepts a pre-computed deps list.
126    /// Avoids repeated `all_dependency_names()` allocation when checking many plugins.
127    fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
128        let enablers = self.enablers();
129        if enablers.is_empty() {
130            return false;
131        }
132        enablers.iter().any(|enabler| {
133            if enabler.ends_with('/') {
134                // Prefix match (e.g., "@storybook/" matches "@storybook/react")
135                deps.iter().any(|d| d.starts_with(enabler))
136            } else {
137                deps.iter().any(|d| d == enabler)
138            }
139        })
140    }
141
142    /// Default glob patterns for entry point files.
143    fn entry_patterns(&self) -> &'static [&'static str] {
144        &[]
145    }
146
147    /// How this plugin's entry patterns should contribute to coverage reachability.
148    ///
149    /// `Support` roots keep files alive for dead-code analysis but do not count
150    /// as runtime or test reachability for static coverage gaps.
151    fn entry_point_role(&self) -> EntryPointRole {
152        builtin_entry_point_role(self.name())
153    }
154
155    /// Glob patterns for config files this plugin can parse.
156    fn config_patterns(&self) -> &'static [&'static str] {
157        &[]
158    }
159
160    /// Files that are always considered "used" when this plugin is active.
161    fn always_used(&self) -> &'static [&'static str] {
162        &[]
163    }
164
165    /// Exports that are always considered used for matching file patterns.
166    fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
167        vec![]
168    }
169
170    /// Glob patterns for test fixture files consumed by this framework.
171    /// These files are implicitly used by the test runner and should not be
172    /// flagged as unused. Unlike `always_used()`, this carries semantic intent
173    /// for reporting purposes.
174    fn fixture_glob_patterns(&self) -> &'static [&'static str] {
175        &[]
176    }
177
178    /// Dependencies that are tooling (used via CLI/config, not source imports).
179    /// These should not be flagged as unused devDependencies.
180    fn tooling_dependencies(&self) -> &'static [&'static str] {
181        &[]
182    }
183
184    /// Import prefixes that are virtual modules provided by this framework at build time.
185    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
186    /// Each entry is matched as a prefix against the extracted package name
187    /// (e.g., `"@theme/"` matches `@theme/Layout`).
188    fn virtual_module_prefixes(&self) -> &'static [&'static str] {
189        &[]
190    }
191
192    /// Import suffixes for build-time generated relative imports.
193    ///
194    /// Unresolved relative imports whose specifier ends with one of these suffixes
195    /// will not be flagged as unresolved. For example, SvelteKit generates
196    /// `./$types` imports in route files — returning `"/$types"` suppresses those.
197    fn generated_import_patterns(&self) -> &'static [&'static str] {
198        &[]
199    }
200
201    /// Path alias mappings provided by this framework at build time.
202    ///
203    /// Returns a list of `(prefix, replacement_dir)` tuples. When an import starting
204    /// with `prefix` fails to resolve, the resolver will substitute the prefix with
205    /// `replacement_dir` (relative to the project root) and retry.
206    ///
207    /// Called once when plugins are activated. The project `root` is provided so
208    /// plugins can inspect the filesystem (e.g., Nuxt checks whether `app/` exists
209    /// to determine the `srcDir`).
210    fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
211        vec![]
212    }
213
214    /// Parse a config file's AST to discover additional entries, dependencies, etc.
215    ///
216    /// Called for each config file matching `config_patterns()`. The source code
217    /// and parsed AST are provided — use [`config_parser`] utilities to extract values.
218    fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
219        PluginResult::default()
220    }
221
222    /// The key name in package.json that holds inline configuration for this tool.
223    /// When set (e.g., `"jest"` for the `"jest"` key in package.json), the plugin
224    /// system will extract that key's value and call `resolve_config` with its
225    /// JSON content if no standalone config file was found.
226    fn package_json_config_key(&self) -> Option<&'static str> {
227        None
228    }
229}
230
231fn builtin_entry_point_role(name: &str) -> EntryPointRole {
232    if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
233        EntryPointRole::Test
234    } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
235        EntryPointRole::Runtime
236    } else {
237        EntryPointRole::Support
238    }
239}
240
241/// Macro to eliminate boilerplate in plugin implementations.
242///
243/// Generates a struct and a `Plugin` trait impl with the standard static methods
244/// (`name`, `enablers`, `entry_patterns`, `config_patterns`, `always_used`, `tooling_dependencies`,
245/// `fixture_glob_patterns`, `used_exports`).
246///
247/// For plugins that need custom `resolve_config()` or `is_enabled()`, keep those as
248/// manual `impl Plugin for ...` blocks instead of using this macro.
249///
250/// # Usage
251///
252/// ```ignore
253/// // Simple plugin (most common):
254/// define_plugin! {
255///     struct VitePlugin => "vite",
256///     enablers: ENABLERS,
257///     entry_patterns: ENTRY_PATTERNS,
258///     config_patterns: CONFIG_PATTERNS,
259///     always_used: ALWAYS_USED,
260///     tooling_dependencies: TOOLING_DEPENDENCIES,
261/// }
262///
263/// // Plugin with used_exports:
264/// define_plugin! {
265///     struct RemixPlugin => "remix",
266///     enablers: ENABLERS,
267///     entry_patterns: ENTRY_PATTERNS,
268///     always_used: ALWAYS_USED,
269///     tooling_dependencies: TOOLING_DEPENDENCIES,
270///     used_exports: [("app/routes/**/*.{ts,tsx}", ROUTE_EXPORTS)],
271/// }
272///
273/// // Plugin with imports-only resolve_config (extracts imports from config as deps):
274/// define_plugin! {
275///     struct CypressPlugin => "cypress",
276///     enablers: ENABLERS,
277///     entry_patterns: ENTRY_PATTERNS,
278///     config_patterns: CONFIG_PATTERNS,
279///     always_used: ALWAYS_USED,
280///     tooling_dependencies: TOOLING_DEPENDENCIES,
281///     resolve_config: imports_only,
282/// }
283/// ```
284///
285/// All fields except `struct` and `enablers` are optional and default to `&[]` / `vec![]`.
286macro_rules! define_plugin {
287    // Variant with `resolve_config: imports_only` — generates a resolve_config method
288    // that extracts imports from config files and registers them as referenced dependencies.
289    (
290        struct $name:ident => $display:expr,
291        enablers: $enablers:expr
292        $(, entry_patterns: $entry:expr)?
293        $(, config_patterns: $config:expr)?
294        $(, always_used: $always:expr)?
295        $(, tooling_dependencies: $tooling:expr)?
296        $(, fixture_glob_patterns: $fixtures:expr)?
297        $(, virtual_module_prefixes: $virtual:expr)?
298        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
299        , resolve_config: imports_only
300        $(,)?
301    ) => {
302        pub struct $name;
303
304        impl Plugin for $name {
305            fn name(&self) -> &'static str {
306                $display
307            }
308
309            fn enablers(&self) -> &'static [&'static str] {
310                $enablers
311            }
312
313            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
314            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
315            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
316            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
317            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
318            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
319
320            $(
321                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
322                    vec![$( ($pat, $exports) ),*]
323                }
324            )?
325
326            fn resolve_config(
327                &self,
328                config_path: &std::path::Path,
329                source: &str,
330                _root: &std::path::Path,
331            ) -> PluginResult {
332                let mut result = PluginResult::default();
333                let imports = crate::plugins::config_parser::extract_imports(source, config_path);
334                for imp in &imports {
335                    let dep = crate::resolve::extract_package_name(imp);
336                    result.referenced_dependencies.push(dep);
337                }
338                result
339            }
340        }
341    };
342
343    // Base variant — no resolve_config.
344    (
345        struct $name:ident => $display:expr,
346        enablers: $enablers:expr
347        $(, entry_patterns: $entry:expr)?
348        $(, config_patterns: $config:expr)?
349        $(, always_used: $always:expr)?
350        $(, tooling_dependencies: $tooling:expr)?
351        $(, fixture_glob_patterns: $fixtures:expr)?
352        $(, virtual_module_prefixes: $virtual:expr)?
353        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
354        $(,)?
355    ) => {
356        pub struct $name;
357
358        impl Plugin for $name {
359            fn name(&self) -> &'static str {
360                $display
361            }
362
363            fn enablers(&self) -> &'static [&'static str] {
364                $enablers
365            }
366
367            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
368            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
369            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
370            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
371            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
372            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
373
374            $(
375                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
376                    vec![$( ($pat, $exports) ),*]
377                }
378            )?
379        }
380    };
381}
382
383pub mod config_parser;
384pub mod registry;
385mod tooling;
386
387pub use registry::{AggregatedPluginResult, PluginRegistry};
388pub use tooling::is_known_tooling_dependency;
389
390mod angular;
391mod astro;
392mod ava;
393mod babel;
394mod biome;
395mod bun;
396mod c8;
397mod capacitor;
398mod changesets;
399mod commitizen;
400mod commitlint;
401mod cspell;
402mod cucumber;
403mod cypress;
404mod dependency_cruiser;
405mod docusaurus;
406mod drizzle;
407mod electron;
408mod eslint;
409mod expo;
410mod gatsby;
411mod graphql_codegen;
412mod husky;
413mod i18next;
414mod jest;
415mod karma;
416mod knex;
417mod kysely;
418mod lefthook;
419mod lint_staged;
420mod markdownlint;
421mod mocha;
422mod msw;
423mod nestjs;
424mod next_intl;
425mod nextjs;
426mod nitro;
427mod nodemon;
428mod nuxt;
429mod nx;
430mod nyc;
431mod openapi_ts;
432mod oxlint;
433mod parcel;
434mod playwright;
435mod plop;
436mod pm2;
437mod postcss;
438mod prettier;
439mod prisma;
440mod react_native;
441mod react_router;
442mod relay;
443mod remark;
444mod remix;
445mod rolldown;
446mod rollup;
447mod rsbuild;
448mod rspack;
449mod sanity;
450mod semantic_release;
451mod sentry;
452mod simple_git_hooks;
453mod storybook;
454mod stylelint;
455mod sveltekit;
456mod svgo;
457mod svgr;
458mod swc;
459mod syncpack;
460mod tailwind;
461mod tanstack_router;
462mod tsdown;
463mod tsup;
464mod turborepo;
465mod typedoc;
466mod typeorm;
467mod typescript;
468mod vite;
469mod vitepress;
470mod vitest;
471mod webdriverio;
472mod webpack;
473mod wrangler;
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use std::path::Path;
479
480    // ── is_enabled_with_deps edge cases ──────────────────────────
481
482    #[test]
483    fn is_enabled_with_deps_exact_match() {
484        let plugin = nextjs::NextJsPlugin;
485        let deps = vec!["next".to_string()];
486        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
487    }
488
489    #[test]
490    fn is_enabled_with_deps_no_match() {
491        let plugin = nextjs::NextJsPlugin;
492        let deps = vec!["react".to_string()];
493        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
494    }
495
496    #[test]
497    fn is_enabled_with_deps_empty_deps() {
498        let plugin = nextjs::NextJsPlugin;
499        let deps: Vec<String> = vec![];
500        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
501    }
502
503    #[test]
504    fn entry_point_role_defaults_are_centralized() {
505        assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
506        assert_eq!(
507            vitest::VitestPlugin.entry_point_role(),
508            EntryPointRole::Test
509        );
510        assert_eq!(
511            storybook::StorybookPlugin.entry_point_role(),
512            EntryPointRole::Support
513        );
514        assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
515    }
516
517    #[test]
518    fn plugins_with_entry_patterns_have_explicit_role_intent() {
519        let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
520            TEST_ENTRY_POINT_PLUGINS
521                .iter()
522                .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
523                .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
524                .copied()
525                .collect();
526
527        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
528            if plugin.entry_patterns().is_empty() {
529                continue;
530            }
531            assert!(
532                runtime_or_test_or_support.contains(plugin.name()),
533                "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
534                plugin.name()
535            );
536        }
537    }
538
539    // ── PluginResult::is_empty ───────────────────────────────────
540
541    #[test]
542    fn plugin_result_is_empty_when_default() {
543        let r = PluginResult::default();
544        assert!(r.is_empty());
545    }
546
547    #[test]
548    fn plugin_result_not_empty_with_entry_patterns() {
549        let r = PluginResult {
550            entry_patterns: vec!["*.ts".to_string()],
551            ..Default::default()
552        };
553        assert!(!r.is_empty());
554    }
555
556    #[test]
557    fn plugin_result_not_empty_with_referenced_deps() {
558        let r = PluginResult {
559            referenced_dependencies: vec!["lodash".to_string()],
560            ..Default::default()
561        };
562        assert!(!r.is_empty());
563    }
564
565    #[test]
566    fn plugin_result_not_empty_with_setup_files() {
567        let r = PluginResult {
568            setup_files: vec![PathBuf::from("/setup.ts")],
569            ..Default::default()
570        };
571        assert!(!r.is_empty());
572    }
573
574    #[test]
575    fn plugin_result_not_empty_with_always_used_files() {
576        let r = PluginResult {
577            always_used_files: vec!["**/*.stories.tsx".to_string()],
578            ..Default::default()
579        };
580        assert!(!r.is_empty());
581    }
582
583    #[test]
584    fn plugin_result_not_empty_with_fixture_patterns() {
585        let r = PluginResult {
586            fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
587            ..Default::default()
588        };
589        assert!(!r.is_empty());
590    }
591
592    // ── is_enabled_with_deps prefix matching ─────────────────────
593
594    #[test]
595    fn is_enabled_with_deps_prefix_match() {
596        // Storybook plugin uses prefix enabler "@storybook/"
597        let plugin = storybook::StorybookPlugin;
598        let deps = vec!["@storybook/react".to_string()];
599        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
600    }
601
602    #[test]
603    fn is_enabled_with_deps_prefix_no_match_without_slash() {
604        // "@storybook/" prefix should NOT match "@storybookish" (different package)
605        let plugin = storybook::StorybookPlugin;
606        let deps = vec!["@storybookish".to_string()];
607        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
608    }
609
610    #[test]
611    fn is_enabled_with_deps_multiple_enablers() {
612        // Vitest plugin has multiple enablers
613        let plugin = vitest::VitestPlugin;
614        let deps_vitest = vec!["vitest".to_string()];
615        let deps_none = vec!["mocha".to_string()];
616        assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
617        assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
618    }
619
620    // ── Plugin trait default implementations ─────────────────────
621
622    #[test]
623    fn plugin_default_methods_return_empty() {
624        // Use a simple plugin to test default trait methods
625        let plugin = commitizen::CommitizenPlugin;
626        assert!(
627            plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
628        );
629        assert!(plugin.virtual_module_prefixes().is_empty());
630        assert!(plugin.path_aliases(Path::new("/project")).is_empty());
631        assert!(
632            plugin.package_json_config_key().is_none()
633                || plugin.package_json_config_key().is_some()
634        );
635    }
636
637    #[test]
638    fn plugin_resolve_config_default_returns_empty() {
639        let plugin = commitizen::CommitizenPlugin;
640        let result = plugin.resolve_config(
641            Path::new("/project/config.js"),
642            "const x = 1;",
643            Path::new("/project"),
644        );
645        assert!(result.is_empty());
646    }
647
648    // ── is_enabled_with_deps exact and prefix ────────────────────
649
650    #[test]
651    fn is_enabled_with_deps_exact_and_prefix_both_work() {
652        let plugin = storybook::StorybookPlugin;
653        let deps_exact = vec!["storybook".to_string()];
654        assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
655        let deps_prefix = vec!["@storybook/vue3".to_string()];
656        assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
657    }
658
659    #[test]
660    fn is_enabled_with_deps_multiple_enablers_remix() {
661        let plugin = remix::RemixPlugin;
662        let deps_node = vec!["@remix-run/node".to_string()];
663        assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
664        let deps_react = vec!["@remix-run/react".to_string()];
665        assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
666        let deps_cf = vec!["@remix-run/cloudflare".to_string()];
667        assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
668    }
669
670    // ── Plugin trait default implementations ──────────────────────
671
672    struct MinimalPlugin;
673    impl Plugin for MinimalPlugin {
674        fn name(&self) -> &'static str {
675            "minimal"
676        }
677    }
678
679    #[test]
680    fn default_enablers_is_empty() {
681        assert!(MinimalPlugin.enablers().is_empty());
682    }
683
684    #[test]
685    fn default_entry_patterns_is_empty() {
686        assert!(MinimalPlugin.entry_patterns().is_empty());
687    }
688
689    #[test]
690    fn default_config_patterns_is_empty() {
691        assert!(MinimalPlugin.config_patterns().is_empty());
692    }
693
694    #[test]
695    fn default_always_used_is_empty() {
696        assert!(MinimalPlugin.always_used().is_empty());
697    }
698
699    #[test]
700    fn default_used_exports_is_empty() {
701        assert!(MinimalPlugin.used_exports().is_empty());
702    }
703
704    #[test]
705    fn default_tooling_dependencies_is_empty() {
706        assert!(MinimalPlugin.tooling_dependencies().is_empty());
707    }
708
709    #[test]
710    fn default_fixture_glob_patterns_is_empty() {
711        assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
712    }
713
714    #[test]
715    fn default_virtual_module_prefixes_is_empty() {
716        assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
717    }
718
719    #[test]
720    fn default_path_aliases_is_empty() {
721        assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
722    }
723
724    #[test]
725    fn default_resolve_config_returns_empty() {
726        let r = MinimalPlugin.resolve_config(
727            Path::new("config.js"),
728            "export default {}",
729            Path::new("/"),
730        );
731        assert!(r.is_empty());
732    }
733
734    #[test]
735    fn default_package_json_config_key_is_none() {
736        assert!(MinimalPlugin.package_json_config_key().is_none());
737    }
738
739    #[test]
740    fn default_is_enabled_returns_false_when_no_enablers() {
741        let deps = vec!["anything".to_string()];
742        assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
743    }
744
745    // ── All built-in plugins have unique names ───────────────────
746
747    #[test]
748    fn all_builtin_plugin_names_are_unique() {
749        let plugins = registry::builtin::create_builtin_plugins();
750        let mut seen = std::collections::BTreeSet::new();
751        for p in &plugins {
752            let name = p.name();
753            assert!(seen.insert(name), "duplicate plugin name: {name}");
754        }
755    }
756
757    #[test]
758    fn all_builtin_plugins_have_enablers() {
759        let plugins = registry::builtin::create_builtin_plugins();
760        for p in &plugins {
761            assert!(
762                !p.enablers().is_empty(),
763                "plugin '{}' has no enablers",
764                p.name()
765            );
766        }
767    }
768
769    #[test]
770    fn plugins_with_config_patterns_have_always_used() {
771        let plugins = registry::builtin::create_builtin_plugins();
772        for p in &plugins {
773            if !p.config_patterns().is_empty() {
774                assert!(
775                    !p.always_used().is_empty(),
776                    "plugin '{}' has config_patterns but no always_used",
777                    p.name()
778                );
779            }
780        }
781    }
782
783    // ── Enabler patterns for all categories ──────────────────────
784
785    #[test]
786    fn framework_plugins_enablers() {
787        let cases: Vec<(&dyn Plugin, &[&str])> = vec![
788            (&nextjs::NextJsPlugin, &["next"]),
789            (&nuxt::NuxtPlugin, &["nuxt"]),
790            (&angular::AngularPlugin, &["@angular/core"]),
791            (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
792            (&gatsby::GatsbyPlugin, &["gatsby"]),
793        ];
794        for (plugin, expected_enablers) in cases {
795            let enablers = plugin.enablers();
796            for expected in expected_enablers {
797                assert!(
798                    enablers.contains(expected),
799                    "plugin '{}' should have '{}'",
800                    plugin.name(),
801                    expected
802                );
803            }
804        }
805    }
806
807    #[test]
808    fn testing_plugins_enablers() {
809        let cases: Vec<(&dyn Plugin, &str)> = vec![
810            (&jest::JestPlugin, "jest"),
811            (&vitest::VitestPlugin, "vitest"),
812            (&playwright::PlaywrightPlugin, "@playwright/test"),
813            (&cypress::CypressPlugin, "cypress"),
814            (&mocha::MochaPlugin, "mocha"),
815        ];
816        for (plugin, enabler) in cases {
817            assert!(
818                plugin.enablers().contains(&enabler),
819                "plugin '{}' should have '{}'",
820                plugin.name(),
821                enabler
822            );
823        }
824    }
825
826    #[test]
827    fn bundler_plugins_enablers() {
828        let cases: Vec<(&dyn Plugin, &str)> = vec![
829            (&vite::VitePlugin, "vite"),
830            (&webpack::WebpackPlugin, "webpack"),
831            (&rollup::RollupPlugin, "rollup"),
832        ];
833        for (plugin, enabler) in cases {
834            assert!(
835                plugin.enablers().contains(&enabler),
836                "plugin '{}' should have '{}'",
837                plugin.name(),
838                enabler
839            );
840        }
841    }
842
843    #[test]
844    fn test_plugins_have_test_entry_patterns() {
845        let test_plugins: Vec<&dyn Plugin> = vec![
846            &jest::JestPlugin,
847            &vitest::VitestPlugin,
848            &mocha::MochaPlugin,
849        ];
850        for plugin in test_plugins {
851            let patterns = plugin.entry_patterns();
852            assert!(
853                !patterns.is_empty(),
854                "test plugin '{}' should have entry patterns",
855                plugin.name()
856            );
857            assert!(
858                patterns
859                    .iter()
860                    .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
861                "test plugin '{}' should have test/spec patterns",
862                plugin.name()
863            );
864        }
865    }
866
867    #[test]
868    fn framework_plugins_have_entry_patterns() {
869        let plugins: Vec<&dyn Plugin> = vec![
870            &nextjs::NextJsPlugin,
871            &nuxt::NuxtPlugin,
872            &angular::AngularPlugin,
873            &sveltekit::SvelteKitPlugin,
874        ];
875        for plugin in plugins {
876            assert!(
877                !plugin.entry_patterns().is_empty(),
878                "framework plugin '{}' should have entry patterns",
879                plugin.name()
880            );
881        }
882    }
883
884    #[test]
885    fn plugins_with_resolve_config_have_config_patterns() {
886        let plugins: Vec<&dyn Plugin> = vec![
887            &jest::JestPlugin,
888            &vitest::VitestPlugin,
889            &babel::BabelPlugin,
890            &eslint::EslintPlugin,
891            &webpack::WebpackPlugin,
892            &storybook::StorybookPlugin,
893            &typescript::TypeScriptPlugin,
894            &postcss::PostCssPlugin,
895            &nextjs::NextJsPlugin,
896            &nuxt::NuxtPlugin,
897            &angular::AngularPlugin,
898            &nx::NxPlugin,
899            &rollup::RollupPlugin,
900            &sveltekit::SvelteKitPlugin,
901            &prettier::PrettierPlugin,
902        ];
903        for plugin in plugins {
904            assert!(
905                !plugin.config_patterns().is_empty(),
906                "plugin '{}' with resolve_config should have config_patterns",
907                plugin.name()
908            );
909        }
910    }
911
912    #[test]
913    fn plugin_tooling_deps_include_enabler_package() {
914        let plugins: Vec<&dyn Plugin> = vec![
915            &jest::JestPlugin,
916            &vitest::VitestPlugin,
917            &webpack::WebpackPlugin,
918            &typescript::TypeScriptPlugin,
919            &eslint::EslintPlugin,
920            &prettier::PrettierPlugin,
921        ];
922        for plugin in plugins {
923            let tooling = plugin.tooling_dependencies();
924            let enablers = plugin.enablers();
925            assert!(
926                enablers
927                    .iter()
928                    .any(|e| !e.ends_with('/') && tooling.contains(e)),
929                "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
930                plugin.name()
931            );
932        }
933    }
934
935    #[test]
936    fn nextjs_has_used_exports_for_pages() {
937        let plugin = nextjs::NextJsPlugin;
938        let exports = plugin.used_exports();
939        assert!(!exports.is_empty());
940        assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
941    }
942
943    #[test]
944    fn remix_has_used_exports_for_routes() {
945        let plugin = remix::RemixPlugin;
946        let exports = plugin.used_exports();
947        assert!(!exports.is_empty());
948        let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
949        assert!(route_entry.is_some());
950        let (_, names) = route_entry.unwrap();
951        assert!(names.contains(&"loader"));
952        assert!(names.contains(&"action"));
953        assert!(names.contains(&"default"));
954    }
955
956    #[test]
957    fn sveltekit_has_used_exports_for_routes() {
958        let plugin = sveltekit::SvelteKitPlugin;
959        let exports = plugin.used_exports();
960        assert!(!exports.is_empty());
961        assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
962    }
963
964    #[test]
965    fn nuxt_has_hash_virtual_prefix() {
966        assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
967    }
968
969    #[test]
970    fn sveltekit_has_dollar_virtual_prefixes() {
971        let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
972        assert!(prefixes.contains(&"$app/"));
973        assert!(prefixes.contains(&"$env/"));
974        assert!(prefixes.contains(&"$lib/"));
975    }
976
977    #[test]
978    fn sveltekit_has_lib_path_alias() {
979        let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
980        assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
981    }
982
983    #[test]
984    fn nuxt_has_tilde_path_alias() {
985        let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
986        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
987        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
988    }
989
990    #[test]
991    fn jest_has_package_json_config_key() {
992        assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
993    }
994
995    #[test]
996    fn babel_has_package_json_config_key() {
997        assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
998    }
999
1000    #[test]
1001    fn eslint_has_package_json_config_key() {
1002        assert_eq!(
1003            eslint::EslintPlugin.package_json_config_key(),
1004            Some("eslintConfig")
1005        );
1006    }
1007
1008    #[test]
1009    fn prettier_has_package_json_config_key() {
1010        assert_eq!(
1011            prettier::PrettierPlugin.package_json_config_key(),
1012            Some("prettier")
1013        );
1014    }
1015
1016    #[test]
1017    fn macro_generated_plugin_basic_properties() {
1018        let plugin = msw::MswPlugin;
1019        assert_eq!(plugin.name(), "msw");
1020        assert!(plugin.enablers().contains(&"msw"));
1021        assert!(!plugin.entry_patterns().is_empty());
1022        assert!(plugin.config_patterns().is_empty());
1023        assert!(!plugin.always_used().is_empty());
1024        assert!(!plugin.tooling_dependencies().is_empty());
1025    }
1026
1027    #[test]
1028    fn macro_generated_plugin_with_used_exports() {
1029        let plugin = remix::RemixPlugin;
1030        assert_eq!(plugin.name(), "remix");
1031        assert!(!plugin.used_exports().is_empty());
1032    }
1033
1034    #[test]
1035    fn macro_generated_plugin_imports_only_resolve_config() {
1036        let plugin = cypress::CypressPlugin;
1037        let source = r"
1038            import { defineConfig } from 'cypress';
1039            import coveragePlugin from '@cypress/code-coverage';
1040            export default defineConfig({});
1041        ";
1042        let result = plugin.resolve_config(
1043            Path::new("cypress.config.ts"),
1044            source,
1045            Path::new("/project"),
1046        );
1047        assert!(
1048            result
1049                .referenced_dependencies
1050                .contains(&"cypress".to_string())
1051        );
1052        assert!(
1053            result
1054                .referenced_dependencies
1055                .contains(&"@cypress/code-coverage".to_string())
1056        );
1057    }
1058
1059    #[test]
1060    fn builtin_plugin_count_is_expected() {
1061        let plugins = registry::builtin::create_builtin_plugins();
1062        assert!(
1063            plugins.len() >= 80,
1064            "expected at least 80 built-in plugins, got {}",
1065            plugins.len()
1066        );
1067    }
1068}