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