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, UsedClassMemberRule};
15use regex::Regex;
16
17const TEST_ENTRY_POINT_PLUGINS: &[&str] = &[
18    "ava",
19    "cucumber",
20    "cypress",
21    "jest",
22    "mocha",
23    "playwright",
24    "vitest",
25    "webdriverio",
26];
27
28const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
29    "angular",
30    "astro",
31    "convex",
32    "docusaurus",
33    "electron",
34    "expo",
35    "expo-router",
36    "gatsby",
37    "hardhat",
38    "nestjs",
39    "next-intl",
40    "nextjs",
41    "nitro",
42    "nuxt",
43    "parcel",
44    "qwik",
45    "react-native",
46    "react-router",
47    "remix",
48    "rolldown",
49    "rollup",
50    "rsbuild",
51    "rspack",
52    "sanity",
53    "sveltekit",
54    "tanstack-router",
55    "tsdown",
56    "tsup",
57    "vite",
58    "vitepress",
59    "webpack",
60    "wrangler",
61];
62
63#[cfg(test)]
64const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
65    "drizzle",
66    "i18next",
67    "knex",
68    "kysely",
69    "msw",
70    "prisma",
71    "storybook",
72    "typeorm",
73];
74
75/// Result of resolving a plugin's config file.
76#[derive(Debug, Default)]
77pub struct PluginResult {
78    /// Additional entry point glob patterns discovered from config.
79    pub entry_patterns: Vec<PathRule>,
80    /// When true, `entry_patterns` from config replace the plugin's static
81    /// `entry_patterns()` defaults instead of adding to them. Tools like Vitest
82    /// and Jest treat their config's include/testMatch as a replacement for built-in
83    /// defaults, so when the config is explicit the static patterns must be dropped.
84    pub replace_entry_patterns: bool,
85    /// When true, `used_exports` from config replace the plugin's static
86    /// `used_export_rules()` defaults instead of adding to them.
87    pub replace_used_export_rules: bool,
88    /// Additional export-usage rules discovered from config.
89    pub used_exports: Vec<UsedExportRule>,
90    /// Class member rules that should never be flagged as unused. Contributed
91    /// by plugins that know their framework invokes these methods at runtime
92    /// and may scope suppression via `extends` / `implements` constraints when
93    /// the method name is too common to allowlist globally.
94    pub used_class_members: Vec<UsedClassMemberRule>,
95    /// Dependencies referenced in config files (should not be flagged as unused).
96    pub referenced_dependencies: Vec<String>,
97    /// Additional files that are always considered used.
98    pub always_used_files: Vec<String>,
99    /// Path alias mappings discovered from config (prefix -> replacement directory).
100    pub path_aliases: Vec<(String, String)>,
101    /// Setup/helper files referenced from config.
102    pub setup_files: Vec<PathBuf>,
103    /// Test fixture glob patterns discovered from config.
104    pub fixture_patterns: Vec<String>,
105    /// Absolute directories to include when resolving SCSS/Sass `@import` and
106    /// `@use` specifiers. Contributed by framework plugins that read their
107    /// tool's equivalent of `includePaths` (e.g. Angular's
108    /// `stylePreprocessorOptions.includePaths` from `angular.json` /
109    /// `project.json`). Bare SCSS specifiers that fail to resolve relative to
110    /// the importing file retry against each include path using the SCSS
111    /// partial / directory-index conventions.
112    pub scss_include_paths: Vec<PathBuf>,
113}
114
115impl PluginResult {
116    pub fn push_entry_pattern(&mut self, pattern: impl Into<String>) {
117        self.entry_patterns.push(PathRule::new(pattern));
118    }
119
120    pub fn extend_entry_patterns<I, S>(&mut self, patterns: I)
121    where
122        I: IntoIterator<Item = S>,
123        S: Into<String>,
124    {
125        self.entry_patterns
126            .extend(patterns.into_iter().map(PathRule::new));
127    }
128
129    pub fn push_used_export_rule(
130        &mut self,
131        pattern: impl Into<String>,
132        exports: impl IntoIterator<Item = impl Into<String>>,
133    ) {
134        self.used_exports
135            .push(UsedExportRule::new(pattern, exports));
136    }
137
138    #[must_use]
139    pub const fn is_empty(&self) -> bool {
140        self.entry_patterns.is_empty()
141            && self.used_exports.is_empty()
142            && self.used_class_members.is_empty()
143            && self.referenced_dependencies.is_empty()
144            && self.always_used_files.is_empty()
145            && self.path_aliases.is_empty()
146            && self.setup_files.is_empty()
147            && self.fixture_patterns.is_empty()
148            && self.scss_include_paths.is_empty()
149    }
150}
151
152/// A file-pattern rule with optional exclusion globs plus path-level or
153/// segment-level regex filters.
154///
155/// Exclusion regexes are matched against the project-relative path and should be
156/// anchored when generated dynamically so they can be safely workspace-prefixed.
157#[derive(Debug, Clone, Default, PartialEq, Eq)]
158pub struct PathRule {
159    pub pattern: String,
160    pub exclude_globs: Vec<String>,
161    pub exclude_regexes: Vec<String>,
162    /// Regexes matched against individual path segments. These are not prefixed
163    /// for workspaces because they intentionally operate on segment names rather
164    /// than the full project-relative path.
165    pub exclude_segment_regexes: Vec<String>,
166}
167
168impl PathRule {
169    #[must_use]
170    pub fn new(pattern: impl Into<String>) -> Self {
171        Self {
172            pattern: pattern.into(),
173            exclude_globs: Vec::new(),
174            exclude_regexes: Vec::new(),
175            exclude_segment_regexes: Vec::new(),
176        }
177    }
178
179    #[must_use]
180    pub fn from_static(pattern: &'static str) -> Self {
181        Self::new(pattern)
182    }
183
184    #[must_use]
185    pub fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
186    where
187        I: IntoIterator<Item = S>,
188        S: Into<String>,
189    {
190        self.exclude_globs
191            .extend(patterns.into_iter().map(Into::into));
192        self
193    }
194
195    #[must_use]
196    pub fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
197    where
198        I: IntoIterator<Item = S>,
199        S: Into<String>,
200    {
201        self.exclude_regexes
202            .extend(patterns.into_iter().map(Into::into));
203        self
204    }
205
206    #[must_use]
207    pub fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
208    where
209        I: IntoIterator<Item = S>,
210        S: Into<String>,
211    {
212        self.exclude_segment_regexes
213            .extend(patterns.into_iter().map(Into::into));
214        self
215    }
216
217    #[must_use]
218    pub fn prefixed(&self, ws_prefix: &str) -> Self {
219        Self {
220            pattern: prefix_workspace_pattern(&self.pattern, ws_prefix),
221            exclude_globs: self
222                .exclude_globs
223                .iter()
224                .map(|pattern| prefix_workspace_pattern(pattern, ws_prefix))
225                .collect(),
226            exclude_regexes: self
227                .exclude_regexes
228                .iter()
229                .map(|pattern| prefix_workspace_regex(pattern, ws_prefix))
230                .collect(),
231            exclude_segment_regexes: self.exclude_segment_regexes.clone(),
232        }
233    }
234}
235
236/// A used-export rule bound to a file-pattern rule.
237#[derive(Debug, Clone, Default, PartialEq, Eq)]
238pub struct UsedExportRule {
239    pub path: PathRule,
240    pub exports: Vec<String>,
241}
242
243impl UsedExportRule {
244    #[must_use]
245    pub fn new(
246        pattern: impl Into<String>,
247        exports: impl IntoIterator<Item = impl Into<String>>,
248    ) -> Self {
249        Self {
250            path: PathRule::new(pattern),
251            exports: exports.into_iter().map(Into::into).collect(),
252        }
253    }
254
255    #[must_use]
256    pub fn from_static(pattern: &'static str, exports: &'static [&'static str]) -> Self {
257        Self::new(pattern, exports.iter().copied())
258    }
259
260    #[must_use]
261    pub fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
262    where
263        I: IntoIterator<Item = S>,
264        S: Into<String>,
265    {
266        self.path = self.path.with_excluded_globs(patterns);
267        self
268    }
269
270    #[must_use]
271    pub fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
272    where
273        I: IntoIterator<Item = S>,
274        S: Into<String>,
275    {
276        self.path = self.path.with_excluded_regexes(patterns);
277        self
278    }
279
280    #[must_use]
281    pub fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
282    where
283        I: IntoIterator<Item = S>,
284        S: Into<String>,
285    {
286        self.path = self.path.with_excluded_segment_regexes(patterns);
287        self
288    }
289
290    #[must_use]
291    pub fn prefixed(&self, ws_prefix: &str) -> Self {
292        Self {
293            path: self.path.prefixed(ws_prefix),
294            exports: self.exports.clone(),
295        }
296    }
297}
298
299/// A used-export rule tagged with the plugin that contributed it.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct PluginUsedExportRule {
302    pub plugin_name: String,
303    pub rule: UsedExportRule,
304}
305
306impl PluginUsedExportRule {
307    #[must_use]
308    pub fn new(plugin_name: impl Into<String>, rule: UsedExportRule) -> Self {
309        Self {
310            plugin_name: plugin_name.into(),
311            rule,
312        }
313    }
314
315    #[must_use]
316    pub fn prefixed(&self, ws_prefix: &str) -> Self {
317        Self {
318            plugin_name: self.plugin_name.clone(),
319            rule: self.rule.prefixed(ws_prefix),
320        }
321    }
322}
323
324/// A compiled path rule matcher shared by entry-point and used-export matching.
325#[derive(Debug, Clone)]
326pub(crate) struct CompiledPathRule {
327    include: globset::GlobMatcher,
328    exclude_globs: Vec<globset::GlobMatcher>,
329    exclude_regexes: Vec<Regex>,
330    exclude_segment_regexes: Vec<Regex>,
331}
332
333impl CompiledPathRule {
334    pub(crate) fn for_entry_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
335        let include = match globset::GlobBuilder::new(&rule.pattern)
336            .literal_separator(true)
337            .build()
338        {
339            Ok(glob) => glob.compile_matcher(),
340            Err(err) => {
341                tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
342                return None;
343            }
344        };
345        Some(Self {
346            include,
347            exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
348            exclude_regexes: compile_excluded_regexes(
349                &rule.exclude_regexes,
350                rule_kind,
351                &rule.pattern,
352            ),
353            exclude_segment_regexes: compile_excluded_segment_regexes(
354                &rule.exclude_segment_regexes,
355                rule_kind,
356                &rule.pattern,
357            ),
358        })
359    }
360
361    pub(crate) fn for_used_export_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
362        let include = match globset::Glob::new(&rule.pattern) {
363            Ok(glob) => glob.compile_matcher(),
364            Err(err) => {
365                tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
366                return None;
367            }
368        };
369        Some(Self {
370            include,
371            exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
372            exclude_regexes: compile_excluded_regexes(
373                &rule.exclude_regexes,
374                rule_kind,
375                &rule.pattern,
376            ),
377            exclude_segment_regexes: compile_excluded_segment_regexes(
378                &rule.exclude_segment_regexes,
379                rule_kind,
380                &rule.pattern,
381            ),
382        })
383    }
384
385    #[must_use]
386    pub(crate) fn matches(&self, path: &str) -> bool {
387        self.include.is_match(path)
388            && !self.exclude_globs.iter().any(|glob| glob.is_match(path))
389            && !self
390                .exclude_regexes
391                .iter()
392                .any(|regex| regex.is_match(path))
393            && !matches_segment_regex(path, &self.exclude_segment_regexes)
394    }
395}
396
397fn prefix_workspace_pattern(pattern: &str, ws_prefix: &str) -> String {
398    if pattern.starts_with(ws_prefix) || pattern.starts_with('/') {
399        pattern.to_string()
400    } else {
401        format!("{ws_prefix}/{pattern}")
402    }
403}
404
405fn prefix_workspace_regex(pattern: &str, ws_prefix: &str) -> String {
406    if let Some(pattern) = pattern.strip_prefix('^') {
407        format!("^{}/{}", regex::escape(ws_prefix), pattern)
408    } else {
409        format!("^{}/(?:{})", regex::escape(ws_prefix), pattern)
410    }
411}
412
413fn compile_excluded_globs(
414    patterns: &[String],
415    rule_kind: &str,
416    rule_pattern: &str,
417) -> Vec<globset::GlobMatcher> {
418    patterns
419        .iter()
420        .filter_map(|pattern| {
421            match globset::GlobBuilder::new(pattern)
422                .literal_separator(true)
423                .build()
424            {
425                Ok(glob) => Some(glob.compile_matcher()),
426                Err(err) => {
427                    tracing::warn!(
428                        "skipping invalid excluded glob '{}' for {} '{}': {err}",
429                        pattern,
430                        rule_kind,
431                        rule_pattern
432                    );
433                    None
434                }
435            }
436        })
437        .collect()
438}
439
440fn compile_excluded_regexes(
441    patterns: &[String],
442    rule_kind: &str,
443    rule_pattern: &str,
444) -> Vec<Regex> {
445    patterns
446        .iter()
447        .filter_map(|pattern| match Regex::new(pattern) {
448            Ok(regex) => Some(regex),
449            Err(err) => {
450                tracing::warn!(
451                    "skipping invalid excluded regex '{}' for {} '{}': {err}",
452                    pattern,
453                    rule_kind,
454                    rule_pattern
455                );
456                None
457            }
458        })
459        .collect()
460}
461
462fn compile_excluded_segment_regexes(
463    patterns: &[String],
464    rule_kind: &str,
465    rule_pattern: &str,
466) -> Vec<Regex> {
467    patterns
468        .iter()
469        .filter_map(|pattern| match Regex::new(pattern) {
470            Ok(regex) => Some(regex),
471            Err(err) => {
472                tracing::warn!(
473                    "skipping invalid excluded segment regex '{}' for {} '{}': {err}",
474                    pattern,
475                    rule_kind,
476                    rule_pattern
477                );
478                None
479            }
480        })
481        .collect()
482}
483
484fn matches_segment_regex(path: &str, regexes: &[Regex]) -> bool {
485    path.split('/')
486        .any(|segment| regexes.iter().any(|regex| regex.is_match(segment)))
487}
488
489impl From<String> for PathRule {
490    fn from(pattern: String) -> Self {
491        Self::new(pattern)
492    }
493}
494
495impl From<&str> for PathRule {
496    fn from(pattern: &str) -> Self {
497        Self::new(pattern)
498    }
499}
500
501impl std::ops::Deref for PathRule {
502    type Target = str;
503
504    fn deref(&self) -> &Self::Target {
505        &self.pattern
506    }
507}
508
509impl PartialEq<&str> for PathRule {
510    fn eq(&self, other: &&str) -> bool {
511        self.pattern == *other
512    }
513}
514
515impl PartialEq<str> for PathRule {
516    fn eq(&self, other: &str) -> bool {
517        self.pattern == other
518    }
519}
520
521impl PartialEq<String> for PathRule {
522    fn eq(&self, other: &String) -> bool {
523        &self.pattern == other
524    }
525}
526
527/// A framework/tool plugin that contributes to dead code analysis.
528pub trait Plugin: Send + Sync {
529    /// Human-readable plugin name.
530    fn name(&self) -> &'static str;
531
532    /// Package names that activate this plugin when found in package.json.
533    /// Supports exact matches and prefix patterns (ending with `/`).
534    fn enablers(&self) -> &'static [&'static str] {
535        &[]
536    }
537
538    /// Check if this plugin should be active for the given project.
539    /// Default implementation checks `enablers()` against package.json dependencies.
540    fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
541        let deps = pkg.all_dependency_names();
542        self.is_enabled_with_deps(&deps, root)
543    }
544
545    /// Fast variant of `is_enabled` that accepts a pre-computed deps list.
546    /// Avoids repeated `all_dependency_names()` allocation when checking many plugins.
547    fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
548        let enablers = self.enablers();
549        if enablers.is_empty() {
550            return false;
551        }
552        enablers.iter().any(|enabler| {
553            if enabler.ends_with('/') {
554                // Prefix match (e.g., "@storybook/" matches "@storybook/react")
555                deps.iter().any(|d| d.starts_with(enabler))
556            } else {
557                deps.iter().any(|d| d == enabler)
558            }
559        })
560    }
561
562    /// Default glob patterns for entry point files.
563    fn entry_patterns(&self) -> &'static [&'static str] {
564        &[]
565    }
566
567    /// Entry point rules with optional exclusions.
568    fn entry_pattern_rules(&self) -> Vec<PathRule> {
569        self.entry_patterns()
570            .iter()
571            .map(|pattern| PathRule::from_static(pattern))
572            .collect()
573    }
574
575    /// How this plugin's entry patterns should contribute to coverage reachability.
576    ///
577    /// `Support` roots keep files alive for dead-code analysis but do not count
578    /// as runtime or test reachability for static coverage gaps.
579    fn entry_point_role(&self) -> EntryPointRole {
580        builtin_entry_point_role(self.name())
581    }
582
583    /// Glob patterns for config files this plugin can parse.
584    fn config_patterns(&self) -> &'static [&'static str] {
585        &[]
586    }
587
588    /// Files that are always considered "used" when this plugin is active.
589    fn always_used(&self) -> &'static [&'static str] {
590        &[]
591    }
592
593    /// Exports that are always considered used for matching file patterns.
594    fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
595        vec![]
596    }
597
598    /// Used-export rules with optional exclusions.
599    fn used_export_rules(&self) -> Vec<UsedExportRule> {
600        self.used_exports()
601            .into_iter()
602            .map(|(pattern, exports)| UsedExportRule::from_static(pattern, exports))
603            .collect()
604    }
605
606    /// Class member names the framework invokes at runtime. Matching members
607    /// are skipped during `unused-class-members` analysis. Intended for
608    /// interface/contract patterns where the library calls methods on consumer
609    /// classes (e.g. ag-Grid's `agInit`, Web Components' `connectedCallback`).
610    fn used_class_members(&self) -> &'static [&'static str] {
611        &[]
612    }
613
614    /// Glob patterns for test fixture files consumed by this framework.
615    /// These files are implicitly used by the test runner and should not be
616    /// flagged as unused. Unlike `always_used()`, this carries semantic intent
617    /// for reporting purposes.
618    fn fixture_glob_patterns(&self) -> &'static [&'static str] {
619        &[]
620    }
621
622    /// Dependencies that are tooling (used via CLI/config, not source imports).
623    /// These should not be flagged as unused devDependencies.
624    fn tooling_dependencies(&self) -> &'static [&'static str] {
625        &[]
626    }
627
628    /// Import prefixes that are virtual modules provided by this framework at build time.
629    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
630    /// Each entry is matched as a prefix against the extracted package name
631    /// (e.g., `"@theme/"` matches `@theme/Layout`).
632    fn virtual_module_prefixes(&self) -> &'static [&'static str] {
633        &[]
634    }
635
636    /// Import suffixes for build-time generated relative imports.
637    ///
638    /// Unresolved relative imports whose specifier ends with one of these suffixes
639    /// will not be flagged as unresolved. For example, SvelteKit generates
640    /// `./$types` imports in route files — returning `"/$types"` suppresses those.
641    fn generated_import_patterns(&self) -> &'static [&'static str] {
642        &[]
643    }
644
645    /// Path alias mappings provided by this framework at build time.
646    ///
647    /// Returns a list of `(prefix, replacement_dir)` tuples. When an import starting
648    /// with `prefix` fails to resolve, the resolver will substitute the prefix with
649    /// `replacement_dir` (relative to the project root) and retry.
650    ///
651    /// Called once when plugins are activated. The project `root` is provided so
652    /// plugins can inspect the filesystem (e.g., Nuxt checks whether `app/` exists
653    /// to determine the `srcDir`).
654    fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
655        vec![]
656    }
657
658    /// Parse a config file's AST to discover additional entries, dependencies, etc.
659    ///
660    /// Called for each config file matching `config_patterns()`. The source code
661    /// and parsed AST are provided — use [`config_parser`] utilities to extract values.
662    fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
663        PluginResult::default()
664    }
665
666    /// The key name in package.json that holds inline configuration for this tool.
667    /// When set (e.g., `"jest"` for the `"jest"` key in package.json), the plugin
668    /// system will extract that key's value and call `resolve_config` with its
669    /// JSON content if no standalone config file was found.
670    fn package_json_config_key(&self) -> Option<&'static str> {
671        None
672    }
673}
674
675fn builtin_entry_point_role(name: &str) -> EntryPointRole {
676    if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
677        EntryPointRole::Test
678    } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
679        EntryPointRole::Runtime
680    } else {
681        EntryPointRole::Support
682    }
683}
684
685/// Macro to eliminate boilerplate in plugin implementations.
686///
687/// Generates a struct and a `Plugin` trait impl with the standard static methods
688/// (`name`, `enablers`, `entry_patterns`, `config_patterns`, `always_used`, `tooling_dependencies`,
689/// `fixture_glob_patterns`, `used_exports`).
690///
691/// For plugins that need custom `resolve_config()` or `is_enabled()`, keep those as
692/// manual `impl Plugin for ...` blocks instead of using this macro.
693///
694/// # Usage
695///
696/// ```ignore
697/// // Simple plugin (most common):
698/// define_plugin! {
699///     struct VitePlugin => "vite",
700///     enablers: ENABLERS,
701///     entry_patterns: ENTRY_PATTERNS,
702///     config_patterns: CONFIG_PATTERNS,
703///     always_used: ALWAYS_USED,
704///     tooling_dependencies: TOOLING_DEPENDENCIES,
705/// }
706///
707/// // Plugin with used_exports:
708/// define_plugin! {
709///     struct RemixPlugin => "remix",
710///     enablers: ENABLERS,
711///     entry_patterns: ENTRY_PATTERNS,
712///     always_used: ALWAYS_USED,
713///     tooling_dependencies: TOOLING_DEPENDENCIES,
714///     used_exports: [("app/routes/**/*.{ts,tsx}", ROUTE_EXPORTS)],
715/// }
716///
717/// // Plugin with imports-only resolve_config (extracts imports from config as deps):
718/// define_plugin! {
719///     struct CypressPlugin => "cypress",
720///     enablers: ENABLERS,
721///     entry_patterns: ENTRY_PATTERNS,
722///     config_patterns: CONFIG_PATTERNS,
723///     always_used: ALWAYS_USED,
724///     tooling_dependencies: TOOLING_DEPENDENCIES,
725///     resolve_config: imports_only,
726/// }
727///
728/// // Plugin with custom resolve_config body:
729/// define_plugin! {
730///     struct RollupPlugin => "rollup",
731///     enablers: ENABLERS,
732///     config_patterns: CONFIG_PATTERNS,
733///     always_used: ALWAYS_USED,
734///     tooling_dependencies: TOOLING_DEPENDENCIES,
735///     resolve_config(config_path, source, _root) {
736///         let mut result = PluginResult::default();
737///         // custom config parsing...
738///         result
739///     }
740/// }
741/// ```
742///
743/// All fields except `struct` and `enablers` are optional and default to `&[]` / `vec![]`.
744macro_rules! define_plugin {
745    // Variant with `resolve_config: imports_only` — generates a resolve_config method
746    // that extracts imports from config files and registers them as referenced dependencies.
747    (
748        struct $name:ident => $display:expr,
749        enablers: $enablers:expr
750        $(, entry_patterns: $entry:expr)?
751        $(, config_patterns: $config:expr)?
752        $(, always_used: $always:expr)?
753        $(, tooling_dependencies: $tooling:expr)?
754        $(, fixture_glob_patterns: $fixtures:expr)?
755        $(, virtual_module_prefixes: $virtual:expr)?
756        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
757        , resolve_config: imports_only
758        $(,)?
759    ) => {
760        pub struct $name;
761
762        impl Plugin for $name {
763            fn name(&self) -> &'static str {
764                $display
765            }
766
767            fn enablers(&self) -> &'static [&'static str] {
768                $enablers
769            }
770
771            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
772            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
773            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
774            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
775            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
776            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
777
778            $(
779                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
780                    vec![$( ($pat, $exports) ),*]
781                }
782            )?
783
784            fn resolve_config(
785                &self,
786                config_path: &std::path::Path,
787                source: &str,
788                _root: &std::path::Path,
789            ) -> PluginResult {
790                let mut result = PluginResult::default();
791                let imports = crate::plugins::config_parser::extract_imports(source, config_path);
792                for imp in &imports {
793                    let dep = crate::resolve::extract_package_name(imp);
794                    result.referenced_dependencies.push(dep);
795                }
796                result
797            }
798        }
799    };
800
801    // Variant with custom resolve_config body — generates a resolve_config method
802    // with the caller-supplied block. Parameter names are caller-controlled (use
803    // `_root` for unused params to satisfy clippy).
804    (
805        struct $name:ident => $display:expr,
806        enablers: $enablers:expr
807        $(, entry_patterns: $entry:expr)?
808        $(, config_patterns: $config:expr)?
809        $(, always_used: $always:expr)?
810        $(, tooling_dependencies: $tooling:expr)?
811        $(, fixture_glob_patterns: $fixtures:expr)?
812        $(, virtual_module_prefixes: $virtual:expr)?
813        $(, package_json_config_key: $pkg_key:expr)?
814        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
815        , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
816        $(,)?
817    ) => {
818        pub struct $name;
819
820        impl Plugin for $name {
821            fn name(&self) -> &'static str {
822                $display
823            }
824
825            fn enablers(&self) -> &'static [&'static str] {
826                $enablers
827            }
828
829            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
830            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
831            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
832            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
833            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
834            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
835
836            $(
837                fn package_json_config_key(&self) -> Option<&'static str> {
838                    Some($pkg_key)
839                }
840            )?
841
842            $(
843                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
844                    vec![$( ($pat, $exports) ),*]
845                }
846            )?
847
848            fn resolve_config(
849                &self,
850                $cp: &std::path::Path,
851                $src: &str,
852                $root: &std::path::Path,
853            ) -> PluginResult
854            $body
855        }
856    };
857
858    // Base variant — no resolve_config.
859    (
860        struct $name:ident => $display:expr,
861        enablers: $enablers:expr
862        $(, entry_patterns: $entry:expr)?
863        $(, config_patterns: $config:expr)?
864        $(, always_used: $always:expr)?
865        $(, tooling_dependencies: $tooling:expr)?
866        $(, fixture_glob_patterns: $fixtures:expr)?
867        $(, virtual_module_prefixes: $virtual:expr)?
868        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
869        $(,)?
870    ) => {
871        pub struct $name;
872
873        impl Plugin for $name {
874            fn name(&self) -> &'static str {
875                $display
876            }
877
878            fn enablers(&self) -> &'static [&'static str] {
879                $enablers
880            }
881
882            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
883            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
884            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
885            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
886            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
887            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
888
889            $(
890                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
891                    vec![$( ($pat, $exports) ),*]
892                }
893            )?
894        }
895    };
896}
897
898pub mod config_parser;
899pub mod registry;
900mod tooling;
901
902pub use registry::{AggregatedPluginResult, PluginRegistry};
903pub use tooling::is_known_tooling_dependency;
904
905mod angular;
906mod astro;
907mod ava;
908mod babel;
909mod biome;
910mod bun;
911mod c8;
912mod capacitor;
913mod changesets;
914mod commitizen;
915mod commitlint;
916mod convex;
917mod cspell;
918mod cucumber;
919mod cypress;
920mod dependency_cruiser;
921mod docusaurus;
922mod drizzle;
923mod electron;
924mod eslint;
925mod expo;
926mod expo_router;
927mod gatsby;
928mod graphql_codegen;
929mod hardhat;
930mod husky;
931mod i18next;
932mod jest;
933mod karma;
934mod knex;
935mod kysely;
936mod lefthook;
937mod lint_staged;
938mod markdownlint;
939mod mocha;
940mod msw;
941mod nestjs;
942mod next_intl;
943mod nextjs;
944mod nitro;
945mod nodemon;
946mod nuxt;
947mod nx;
948mod nyc;
949mod openapi_ts;
950mod oxlint;
951mod pandacss;
952mod parcel;
953mod playwright;
954mod plop;
955mod pm2;
956mod pnpm;
957mod postcss;
958mod prettier;
959mod prisma;
960mod qwik;
961mod react_native;
962mod react_router;
963mod relay;
964mod remark;
965mod remix;
966mod rolldown;
967mod rollup;
968mod rsbuild;
969mod rspack;
970mod sanity;
971mod semantic_release;
972mod sentry;
973mod simple_git_hooks;
974mod storybook;
975mod stylelint;
976mod sveltekit;
977mod svgo;
978mod svgr;
979mod swc;
980mod syncpack;
981mod tailwind;
982mod tanstack_router;
983mod tsdown;
984mod tsup;
985mod turborepo;
986mod typedoc;
987mod typeorm;
988mod typescript;
989mod unocss;
990mod vite;
991mod vitepress;
992mod vitest;
993mod webdriverio;
994mod webpack;
995mod wrangler;
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000    use std::path::Path;
1001
1002    // ── is_enabled_with_deps edge cases ──────────────────────────
1003
1004    #[test]
1005    fn is_enabled_with_deps_exact_match() {
1006        let plugin = nextjs::NextJsPlugin;
1007        let deps = vec!["next".to_string()];
1008        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1009    }
1010
1011    #[test]
1012    fn is_enabled_with_deps_no_match() {
1013        let plugin = nextjs::NextJsPlugin;
1014        let deps = vec!["react".to_string()];
1015        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1016    }
1017
1018    #[test]
1019    fn is_enabled_with_deps_empty_deps() {
1020        let plugin = nextjs::NextJsPlugin;
1021        let deps: Vec<String> = vec![];
1022        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1023    }
1024
1025    #[test]
1026    fn entry_point_role_defaults_are_centralized() {
1027        assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
1028        assert_eq!(
1029            vitest::VitestPlugin.entry_point_role(),
1030            EntryPointRole::Test
1031        );
1032        assert_eq!(
1033            storybook::StorybookPlugin.entry_point_role(),
1034            EntryPointRole::Support
1035        );
1036        assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1037    }
1038
1039    #[test]
1040    fn plugins_with_entry_patterns_have_explicit_role_intent() {
1041        let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1042            TEST_ENTRY_POINT_PLUGINS
1043                .iter()
1044                .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1045                .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1046                .copied()
1047                .collect();
1048
1049        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1050            if plugin.entry_patterns().is_empty() {
1051                continue;
1052            }
1053            assert!(
1054                runtime_or_test_or_support.contains(plugin.name()),
1055                "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1056                plugin.name()
1057            );
1058        }
1059    }
1060
1061    // ── PluginResult::is_empty ───────────────────────────────────
1062
1063    #[test]
1064    fn plugin_result_is_empty_when_default() {
1065        let r = PluginResult::default();
1066        assert!(r.is_empty());
1067    }
1068
1069    #[test]
1070    fn plugin_result_not_empty_with_entry_patterns() {
1071        let r = PluginResult {
1072            entry_patterns: vec!["*.ts".into()],
1073            ..Default::default()
1074        };
1075        assert!(!r.is_empty());
1076    }
1077
1078    #[test]
1079    fn plugin_result_not_empty_with_referenced_deps() {
1080        let r = PluginResult {
1081            referenced_dependencies: vec!["lodash".to_string()],
1082            ..Default::default()
1083        };
1084        assert!(!r.is_empty());
1085    }
1086
1087    #[test]
1088    fn plugin_result_not_empty_with_setup_files() {
1089        let r = PluginResult {
1090            setup_files: vec![PathBuf::from("/setup.ts")],
1091            ..Default::default()
1092        };
1093        assert!(!r.is_empty());
1094    }
1095
1096    #[test]
1097    fn plugin_result_not_empty_with_always_used_files() {
1098        let r = PluginResult {
1099            always_used_files: vec!["**/*.stories.tsx".to_string()],
1100            ..Default::default()
1101        };
1102        assert!(!r.is_empty());
1103    }
1104
1105    #[test]
1106    fn plugin_result_not_empty_with_fixture_patterns() {
1107        let r = PluginResult {
1108            fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
1109            ..Default::default()
1110        };
1111        assert!(!r.is_empty());
1112    }
1113
1114    // ── is_enabled_with_deps prefix matching ─────────────────────
1115
1116    #[test]
1117    fn is_enabled_with_deps_prefix_match() {
1118        // Storybook plugin uses prefix enabler "@storybook/"
1119        let plugin = storybook::StorybookPlugin;
1120        let deps = vec!["@storybook/react".to_string()];
1121        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1122    }
1123
1124    #[test]
1125    fn is_enabled_with_deps_prefix_no_match_without_slash() {
1126        // "@storybook/" prefix should NOT match "@storybookish" (different package)
1127        let plugin = storybook::StorybookPlugin;
1128        let deps = vec!["@storybookish".to_string()];
1129        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1130    }
1131
1132    #[test]
1133    fn is_enabled_with_deps_multiple_enablers() {
1134        // Vitest plugin has multiple enablers
1135        let plugin = vitest::VitestPlugin;
1136        let deps_vitest = vec!["vitest".to_string()];
1137        let deps_none = vec!["mocha".to_string()];
1138        assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
1139        assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
1140    }
1141
1142    // ── Plugin trait default implementations ─────────────────────
1143
1144    #[test]
1145    fn plugin_default_methods_return_empty() {
1146        // Use a simple plugin to test default trait methods
1147        let plugin = commitizen::CommitizenPlugin;
1148        assert!(
1149            plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
1150        );
1151        assert!(plugin.virtual_module_prefixes().is_empty());
1152        assert!(plugin.path_aliases(Path::new("/project")).is_empty());
1153        assert!(
1154            plugin.package_json_config_key().is_none()
1155                || plugin.package_json_config_key().is_some()
1156        );
1157    }
1158
1159    #[test]
1160    fn plugin_resolve_config_default_returns_empty() {
1161        let plugin = commitizen::CommitizenPlugin;
1162        let result = plugin.resolve_config(
1163            Path::new("/project/config.js"),
1164            "const x = 1;",
1165            Path::new("/project"),
1166        );
1167        assert!(result.is_empty());
1168    }
1169
1170    // ── is_enabled_with_deps exact and prefix ────────────────────
1171
1172    #[test]
1173    fn is_enabled_with_deps_exact_and_prefix_both_work() {
1174        let plugin = storybook::StorybookPlugin;
1175        let deps_exact = vec!["storybook".to_string()];
1176        assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
1177        let deps_prefix = vec!["@storybook/vue3".to_string()];
1178        assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
1179    }
1180
1181    #[test]
1182    fn is_enabled_with_deps_multiple_enablers_remix() {
1183        let plugin = remix::RemixPlugin;
1184        let deps_node = vec!["@remix-run/node".to_string()];
1185        assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
1186        let deps_react = vec!["@remix-run/react".to_string()];
1187        assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
1188        let deps_cf = vec!["@remix-run/cloudflare".to_string()];
1189        assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
1190    }
1191
1192    // ── Plugin trait default implementations ──────────────────────
1193
1194    struct MinimalPlugin;
1195    impl Plugin for MinimalPlugin {
1196        fn name(&self) -> &'static str {
1197            "minimal"
1198        }
1199    }
1200
1201    #[test]
1202    fn default_enablers_is_empty() {
1203        assert!(MinimalPlugin.enablers().is_empty());
1204    }
1205
1206    #[test]
1207    fn default_entry_patterns_is_empty() {
1208        assert!(MinimalPlugin.entry_patterns().is_empty());
1209    }
1210
1211    #[test]
1212    fn default_config_patterns_is_empty() {
1213        assert!(MinimalPlugin.config_patterns().is_empty());
1214    }
1215
1216    #[test]
1217    fn default_always_used_is_empty() {
1218        assert!(MinimalPlugin.always_used().is_empty());
1219    }
1220
1221    #[test]
1222    fn default_used_exports_is_empty() {
1223        assert!(MinimalPlugin.used_exports().is_empty());
1224    }
1225
1226    #[test]
1227    fn default_tooling_dependencies_is_empty() {
1228        assert!(MinimalPlugin.tooling_dependencies().is_empty());
1229    }
1230
1231    #[test]
1232    fn default_fixture_glob_patterns_is_empty() {
1233        assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
1234    }
1235
1236    #[test]
1237    fn default_virtual_module_prefixes_is_empty() {
1238        assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
1239    }
1240
1241    #[test]
1242    fn default_path_aliases_is_empty() {
1243        assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
1244    }
1245
1246    #[test]
1247    fn default_resolve_config_returns_empty() {
1248        let r = MinimalPlugin.resolve_config(
1249            Path::new("config.js"),
1250            "export default {}",
1251            Path::new("/"),
1252        );
1253        assert!(r.is_empty());
1254    }
1255
1256    #[test]
1257    fn default_package_json_config_key_is_none() {
1258        assert!(MinimalPlugin.package_json_config_key().is_none());
1259    }
1260
1261    #[test]
1262    fn default_is_enabled_returns_false_when_no_enablers() {
1263        let deps = vec!["anything".to_string()];
1264        assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
1265    }
1266
1267    // ── All built-in plugins have unique names ───────────────────
1268
1269    #[test]
1270    fn all_builtin_plugin_names_are_unique() {
1271        let plugins = registry::builtin::create_builtin_plugins();
1272        let mut seen = std::collections::BTreeSet::new();
1273        for p in &plugins {
1274            let name = p.name();
1275            assert!(seen.insert(name), "duplicate plugin name: {name}");
1276        }
1277    }
1278
1279    #[test]
1280    fn all_builtin_plugins_have_enablers() {
1281        let plugins = registry::builtin::create_builtin_plugins();
1282        for p in &plugins {
1283            assert!(
1284                !p.enablers().is_empty(),
1285                "plugin '{}' has no enablers",
1286                p.name()
1287            );
1288        }
1289    }
1290
1291    #[test]
1292    fn plugins_with_config_patterns_have_always_used() {
1293        let plugins = registry::builtin::create_builtin_plugins();
1294        for p in &plugins {
1295            if !p.config_patterns().is_empty() {
1296                assert!(
1297                    !p.always_used().is_empty(),
1298                    "plugin '{}' has config_patterns but no always_used",
1299                    p.name()
1300                );
1301            }
1302        }
1303    }
1304
1305    // ── Enabler patterns for all categories ──────────────────────
1306
1307    #[test]
1308    fn framework_plugins_enablers() {
1309        let cases: Vec<(&dyn Plugin, &[&str])> = vec![
1310            (&nextjs::NextJsPlugin, &["next"]),
1311            (&nuxt::NuxtPlugin, &["nuxt"]),
1312            (&angular::AngularPlugin, &["@angular/core"]),
1313            (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
1314            (&gatsby::GatsbyPlugin, &["gatsby"]),
1315        ];
1316        for (plugin, expected_enablers) in cases {
1317            let enablers = plugin.enablers();
1318            for expected in expected_enablers {
1319                assert!(
1320                    enablers.contains(expected),
1321                    "plugin '{}' should have '{}'",
1322                    plugin.name(),
1323                    expected
1324                );
1325            }
1326        }
1327    }
1328
1329    #[test]
1330    fn testing_plugins_enablers() {
1331        let cases: Vec<(&dyn Plugin, &str)> = vec![
1332            (&jest::JestPlugin, "jest"),
1333            (&vitest::VitestPlugin, "vitest"),
1334            (&playwright::PlaywrightPlugin, "@playwright/test"),
1335            (&cypress::CypressPlugin, "cypress"),
1336            (&mocha::MochaPlugin, "mocha"),
1337        ];
1338        for (plugin, enabler) in cases {
1339            assert!(
1340                plugin.enablers().contains(&enabler),
1341                "plugin '{}' should have '{}'",
1342                plugin.name(),
1343                enabler
1344            );
1345        }
1346    }
1347
1348    #[test]
1349    fn bundler_plugins_enablers() {
1350        let cases: Vec<(&dyn Plugin, &str)> = vec![
1351            (&vite::VitePlugin, "vite"),
1352            (&webpack::WebpackPlugin, "webpack"),
1353            (&rollup::RollupPlugin, "rollup"),
1354        ];
1355        for (plugin, enabler) in cases {
1356            assert!(
1357                plugin.enablers().contains(&enabler),
1358                "plugin '{}' should have '{}'",
1359                plugin.name(),
1360                enabler
1361            );
1362        }
1363    }
1364
1365    #[test]
1366    fn test_plugins_have_test_entry_patterns() {
1367        let test_plugins: Vec<&dyn Plugin> = vec![
1368            &jest::JestPlugin,
1369            &vitest::VitestPlugin,
1370            &mocha::MochaPlugin,
1371        ];
1372        for plugin in test_plugins {
1373            let patterns = plugin.entry_patterns();
1374            assert!(
1375                !patterns.is_empty(),
1376                "test plugin '{}' should have entry patterns",
1377                plugin.name()
1378            );
1379            assert!(
1380                patterns
1381                    .iter()
1382                    .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
1383                "test plugin '{}' should have test/spec patterns",
1384                plugin.name()
1385            );
1386        }
1387    }
1388
1389    #[test]
1390    fn framework_plugins_have_entry_patterns() {
1391        let plugins: Vec<&dyn Plugin> = vec![
1392            &nextjs::NextJsPlugin,
1393            &nuxt::NuxtPlugin,
1394            &angular::AngularPlugin,
1395            &sveltekit::SvelteKitPlugin,
1396        ];
1397        for plugin in plugins {
1398            assert!(
1399                !plugin.entry_patterns().is_empty(),
1400                "framework plugin '{}' should have entry patterns",
1401                plugin.name()
1402            );
1403        }
1404    }
1405
1406    #[test]
1407    fn plugins_with_resolve_config_have_config_patterns() {
1408        let plugins: Vec<&dyn Plugin> = vec![
1409            &jest::JestPlugin,
1410            &vitest::VitestPlugin,
1411            &babel::BabelPlugin,
1412            &eslint::EslintPlugin,
1413            &webpack::WebpackPlugin,
1414            &storybook::StorybookPlugin,
1415            &typescript::TypeScriptPlugin,
1416            &postcss::PostCssPlugin,
1417            &nextjs::NextJsPlugin,
1418            &nuxt::NuxtPlugin,
1419            &angular::AngularPlugin,
1420            &nx::NxPlugin,
1421            &rollup::RollupPlugin,
1422            &sveltekit::SvelteKitPlugin,
1423            &prettier::PrettierPlugin,
1424        ];
1425        for plugin in plugins {
1426            assert!(
1427                !plugin.config_patterns().is_empty(),
1428                "plugin '{}' with resolve_config should have config_patterns",
1429                plugin.name()
1430            );
1431        }
1432    }
1433
1434    #[test]
1435    fn plugin_tooling_deps_include_enabler_package() {
1436        let plugins: Vec<&dyn Plugin> = vec![
1437            &jest::JestPlugin,
1438            &vitest::VitestPlugin,
1439            &webpack::WebpackPlugin,
1440            &typescript::TypeScriptPlugin,
1441            &eslint::EslintPlugin,
1442            &prettier::PrettierPlugin,
1443        ];
1444        for plugin in plugins {
1445            let tooling = plugin.tooling_dependencies();
1446            let enablers = plugin.enablers();
1447            assert!(
1448                enablers
1449                    .iter()
1450                    .any(|e| !e.ends_with('/') && tooling.contains(e)),
1451                "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
1452                plugin.name()
1453            );
1454        }
1455    }
1456
1457    #[test]
1458    fn nextjs_has_used_exports_for_pages() {
1459        let plugin = nextjs::NextJsPlugin;
1460        let exports = plugin.used_exports();
1461        assert!(!exports.is_empty());
1462        assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
1463    }
1464
1465    #[test]
1466    fn remix_has_used_exports_for_routes() {
1467        let plugin = remix::RemixPlugin;
1468        let exports = plugin.used_exports();
1469        assert!(!exports.is_empty());
1470        let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
1471        assert!(route_entry.is_some());
1472        let (_, names) = route_entry.unwrap();
1473        assert!(names.contains(&"loader"));
1474        assert!(names.contains(&"action"));
1475        assert!(names.contains(&"default"));
1476    }
1477
1478    #[test]
1479    fn sveltekit_has_used_exports_for_routes() {
1480        let plugin = sveltekit::SvelteKitPlugin;
1481        let exports = plugin.used_exports();
1482        assert!(!exports.is_empty());
1483        assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
1484    }
1485
1486    #[test]
1487    fn nuxt_has_hash_virtual_prefix() {
1488        assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
1489    }
1490
1491    #[test]
1492    fn sveltekit_has_dollar_virtual_prefixes() {
1493        let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
1494        assert!(prefixes.contains(&"$app/"));
1495        assert!(prefixes.contains(&"$env/"));
1496        assert!(prefixes.contains(&"$lib/"));
1497    }
1498
1499    #[test]
1500    fn sveltekit_has_lib_path_alias() {
1501        let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
1502        assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
1503    }
1504
1505    #[test]
1506    fn nuxt_has_tilde_path_alias() {
1507        let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
1508        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
1509        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
1510    }
1511
1512    #[test]
1513    fn jest_has_package_json_config_key() {
1514        assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
1515    }
1516
1517    #[test]
1518    fn babel_has_package_json_config_key() {
1519        assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
1520    }
1521
1522    #[test]
1523    fn eslint_has_package_json_config_key() {
1524        assert_eq!(
1525            eslint::EslintPlugin.package_json_config_key(),
1526            Some("eslintConfig")
1527        );
1528    }
1529
1530    #[test]
1531    fn prettier_has_package_json_config_key() {
1532        assert_eq!(
1533            prettier::PrettierPlugin.package_json_config_key(),
1534            Some("prettier")
1535        );
1536    }
1537
1538    #[test]
1539    fn macro_generated_plugin_basic_properties() {
1540        let plugin = msw::MswPlugin;
1541        assert_eq!(plugin.name(), "msw");
1542        assert!(plugin.enablers().contains(&"msw"));
1543        assert!(!plugin.entry_patterns().is_empty());
1544        assert!(plugin.config_patterns().is_empty());
1545        assert!(!plugin.always_used().is_empty());
1546        assert!(!plugin.tooling_dependencies().is_empty());
1547    }
1548
1549    #[test]
1550    fn macro_generated_plugin_with_used_exports() {
1551        let plugin = remix::RemixPlugin;
1552        assert_eq!(plugin.name(), "remix");
1553        assert!(!plugin.used_exports().is_empty());
1554    }
1555
1556    #[test]
1557    fn macro_generated_plugin_imports_only_resolve_config() {
1558        let plugin = cypress::CypressPlugin;
1559        let source = r"
1560            import { defineConfig } from 'cypress';
1561            import coveragePlugin from '@cypress/code-coverage';
1562            export default defineConfig({});
1563        ";
1564        let result = plugin.resolve_config(
1565            Path::new("cypress.config.ts"),
1566            source,
1567            Path::new("/project"),
1568        );
1569        assert!(
1570            result
1571                .referenced_dependencies
1572                .contains(&"cypress".to_string())
1573        );
1574        assert!(
1575            result
1576                .referenced_dependencies
1577                .contains(&"@cypress/code-coverage".to_string())
1578        );
1579    }
1580
1581    #[test]
1582    fn builtin_plugin_count_is_expected() {
1583        let plugins = registry::builtin::create_builtin_plugins();
1584        assert!(
1585            plugins.len() >= 80,
1586            "expected at least 80 built-in plugins, got {}",
1587            plugins.len()
1588        );
1589    }
1590}