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