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