Skip to main content

fallow_core/plugins/
mod.rs

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