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