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