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