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