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};
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 names that should never be flagged as unused. Contributed
91    /// by plugins that know their framework invokes these methods at runtime
92    /// (e.g. ag-Grid's `agInit`, `refresh`). Merged with the built-in Angular/React
93    /// lifecycle allowlist during unused-class-member analysis.
94    pub used_class_members: Vec<String>,
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 parcel;
952mod playwright;
953mod plop;
954mod pm2;
955mod pnpm;
956mod postcss;
957mod prettier;
958mod prisma;
959mod qwik;
960mod react_native;
961mod react_router;
962mod relay;
963mod remark;
964mod remix;
965mod rolldown;
966mod rollup;
967mod rsbuild;
968mod rspack;
969mod sanity;
970mod semantic_release;
971mod sentry;
972mod simple_git_hooks;
973mod storybook;
974mod stylelint;
975mod sveltekit;
976mod svgo;
977mod svgr;
978mod swc;
979mod syncpack;
980mod tailwind;
981mod tanstack_router;
982mod tsdown;
983mod tsup;
984mod turborepo;
985mod typedoc;
986mod typeorm;
987mod typescript;
988mod unocss;
989mod vite;
990mod vitepress;
991mod vitest;
992mod webdriverio;
993mod webpack;
994mod wrangler;
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999    use std::path::Path;
1000
1001    // ── is_enabled_with_deps edge cases ──────────────────────────
1002
1003    #[test]
1004    fn is_enabled_with_deps_exact_match() {
1005        let plugin = nextjs::NextJsPlugin;
1006        let deps = vec!["next".to_string()];
1007        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1008    }
1009
1010    #[test]
1011    fn is_enabled_with_deps_no_match() {
1012        let plugin = nextjs::NextJsPlugin;
1013        let deps = vec!["react".to_string()];
1014        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1015    }
1016
1017    #[test]
1018    fn is_enabled_with_deps_empty_deps() {
1019        let plugin = nextjs::NextJsPlugin;
1020        let deps: Vec<String> = vec![];
1021        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1022    }
1023
1024    #[test]
1025    fn entry_point_role_defaults_are_centralized() {
1026        assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
1027        assert_eq!(
1028            vitest::VitestPlugin.entry_point_role(),
1029            EntryPointRole::Test
1030        );
1031        assert_eq!(
1032            storybook::StorybookPlugin.entry_point_role(),
1033            EntryPointRole::Support
1034        );
1035        assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1036    }
1037
1038    #[test]
1039    fn plugins_with_entry_patterns_have_explicit_role_intent() {
1040        let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1041            TEST_ENTRY_POINT_PLUGINS
1042                .iter()
1043                .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1044                .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1045                .copied()
1046                .collect();
1047
1048        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1049            if plugin.entry_patterns().is_empty() {
1050                continue;
1051            }
1052            assert!(
1053                runtime_or_test_or_support.contains(plugin.name()),
1054                "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1055                plugin.name()
1056            );
1057        }
1058    }
1059
1060    // ── PluginResult::is_empty ───────────────────────────────────
1061
1062    #[test]
1063    fn plugin_result_is_empty_when_default() {
1064        let r = PluginResult::default();
1065        assert!(r.is_empty());
1066    }
1067
1068    #[test]
1069    fn plugin_result_not_empty_with_entry_patterns() {
1070        let r = PluginResult {
1071            entry_patterns: vec!["*.ts".into()],
1072            ..Default::default()
1073        };
1074        assert!(!r.is_empty());
1075    }
1076
1077    #[test]
1078    fn plugin_result_not_empty_with_referenced_deps() {
1079        let r = PluginResult {
1080            referenced_dependencies: vec!["lodash".to_string()],
1081            ..Default::default()
1082        };
1083        assert!(!r.is_empty());
1084    }
1085
1086    #[test]
1087    fn plugin_result_not_empty_with_setup_files() {
1088        let r = PluginResult {
1089            setup_files: vec![PathBuf::from("/setup.ts")],
1090            ..Default::default()
1091        };
1092        assert!(!r.is_empty());
1093    }
1094
1095    #[test]
1096    fn plugin_result_not_empty_with_always_used_files() {
1097        let r = PluginResult {
1098            always_used_files: vec!["**/*.stories.tsx".to_string()],
1099            ..Default::default()
1100        };
1101        assert!(!r.is_empty());
1102    }
1103
1104    #[test]
1105    fn plugin_result_not_empty_with_fixture_patterns() {
1106        let r = PluginResult {
1107            fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
1108            ..Default::default()
1109        };
1110        assert!(!r.is_empty());
1111    }
1112
1113    // ── is_enabled_with_deps prefix matching ─────────────────────
1114
1115    #[test]
1116    fn is_enabled_with_deps_prefix_match() {
1117        // Storybook plugin uses prefix enabler "@storybook/"
1118        let plugin = storybook::StorybookPlugin;
1119        let deps = vec!["@storybook/react".to_string()];
1120        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1121    }
1122
1123    #[test]
1124    fn is_enabled_with_deps_prefix_no_match_without_slash() {
1125        // "@storybook/" prefix should NOT match "@storybookish" (different package)
1126        let plugin = storybook::StorybookPlugin;
1127        let deps = vec!["@storybookish".to_string()];
1128        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1129    }
1130
1131    #[test]
1132    fn is_enabled_with_deps_multiple_enablers() {
1133        // Vitest plugin has multiple enablers
1134        let plugin = vitest::VitestPlugin;
1135        let deps_vitest = vec!["vitest".to_string()];
1136        let deps_none = vec!["mocha".to_string()];
1137        assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
1138        assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
1139    }
1140
1141    // ── Plugin trait default implementations ─────────────────────
1142
1143    #[test]
1144    fn plugin_default_methods_return_empty() {
1145        // Use a simple plugin to test default trait methods
1146        let plugin = commitizen::CommitizenPlugin;
1147        assert!(
1148            plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
1149        );
1150        assert!(plugin.virtual_module_prefixes().is_empty());
1151        assert!(plugin.path_aliases(Path::new("/project")).is_empty());
1152        assert!(
1153            plugin.package_json_config_key().is_none()
1154                || plugin.package_json_config_key().is_some()
1155        );
1156    }
1157
1158    #[test]
1159    fn plugin_resolve_config_default_returns_empty() {
1160        let plugin = commitizen::CommitizenPlugin;
1161        let result = plugin.resolve_config(
1162            Path::new("/project/config.js"),
1163            "const x = 1;",
1164            Path::new("/project"),
1165        );
1166        assert!(result.is_empty());
1167    }
1168
1169    // ── is_enabled_with_deps exact and prefix ────────────────────
1170
1171    #[test]
1172    fn is_enabled_with_deps_exact_and_prefix_both_work() {
1173        let plugin = storybook::StorybookPlugin;
1174        let deps_exact = vec!["storybook".to_string()];
1175        assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
1176        let deps_prefix = vec!["@storybook/vue3".to_string()];
1177        assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
1178    }
1179
1180    #[test]
1181    fn is_enabled_with_deps_multiple_enablers_remix() {
1182        let plugin = remix::RemixPlugin;
1183        let deps_node = vec!["@remix-run/node".to_string()];
1184        assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
1185        let deps_react = vec!["@remix-run/react".to_string()];
1186        assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
1187        let deps_cf = vec!["@remix-run/cloudflare".to_string()];
1188        assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
1189    }
1190
1191    // ── Plugin trait default implementations ──────────────────────
1192
1193    struct MinimalPlugin;
1194    impl Plugin for MinimalPlugin {
1195        fn name(&self) -> &'static str {
1196            "minimal"
1197        }
1198    }
1199
1200    #[test]
1201    fn default_enablers_is_empty() {
1202        assert!(MinimalPlugin.enablers().is_empty());
1203    }
1204
1205    #[test]
1206    fn default_entry_patterns_is_empty() {
1207        assert!(MinimalPlugin.entry_patterns().is_empty());
1208    }
1209
1210    #[test]
1211    fn default_config_patterns_is_empty() {
1212        assert!(MinimalPlugin.config_patterns().is_empty());
1213    }
1214
1215    #[test]
1216    fn default_always_used_is_empty() {
1217        assert!(MinimalPlugin.always_used().is_empty());
1218    }
1219
1220    #[test]
1221    fn default_used_exports_is_empty() {
1222        assert!(MinimalPlugin.used_exports().is_empty());
1223    }
1224
1225    #[test]
1226    fn default_tooling_dependencies_is_empty() {
1227        assert!(MinimalPlugin.tooling_dependencies().is_empty());
1228    }
1229
1230    #[test]
1231    fn default_fixture_glob_patterns_is_empty() {
1232        assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
1233    }
1234
1235    #[test]
1236    fn default_virtual_module_prefixes_is_empty() {
1237        assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
1238    }
1239
1240    #[test]
1241    fn default_path_aliases_is_empty() {
1242        assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
1243    }
1244
1245    #[test]
1246    fn default_resolve_config_returns_empty() {
1247        let r = MinimalPlugin.resolve_config(
1248            Path::new("config.js"),
1249            "export default {}",
1250            Path::new("/"),
1251        );
1252        assert!(r.is_empty());
1253    }
1254
1255    #[test]
1256    fn default_package_json_config_key_is_none() {
1257        assert!(MinimalPlugin.package_json_config_key().is_none());
1258    }
1259
1260    #[test]
1261    fn default_is_enabled_returns_false_when_no_enablers() {
1262        let deps = vec!["anything".to_string()];
1263        assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
1264    }
1265
1266    // ── All built-in plugins have unique names ───────────────────
1267
1268    #[test]
1269    fn all_builtin_plugin_names_are_unique() {
1270        let plugins = registry::builtin::create_builtin_plugins();
1271        let mut seen = std::collections::BTreeSet::new();
1272        for p in &plugins {
1273            let name = p.name();
1274            assert!(seen.insert(name), "duplicate plugin name: {name}");
1275        }
1276    }
1277
1278    #[test]
1279    fn all_builtin_plugins_have_enablers() {
1280        let plugins = registry::builtin::create_builtin_plugins();
1281        for p in &plugins {
1282            assert!(
1283                !p.enablers().is_empty(),
1284                "plugin '{}' has no enablers",
1285                p.name()
1286            );
1287        }
1288    }
1289
1290    #[test]
1291    fn plugins_with_config_patterns_have_always_used() {
1292        let plugins = registry::builtin::create_builtin_plugins();
1293        for p in &plugins {
1294            if !p.config_patterns().is_empty() {
1295                assert!(
1296                    !p.always_used().is_empty(),
1297                    "plugin '{}' has config_patterns but no always_used",
1298                    p.name()
1299                );
1300            }
1301        }
1302    }
1303
1304    // ── Enabler patterns for all categories ──────────────────────
1305
1306    #[test]
1307    fn framework_plugins_enablers() {
1308        let cases: Vec<(&dyn Plugin, &[&str])> = vec![
1309            (&nextjs::NextJsPlugin, &["next"]),
1310            (&nuxt::NuxtPlugin, &["nuxt"]),
1311            (&angular::AngularPlugin, &["@angular/core"]),
1312            (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
1313            (&gatsby::GatsbyPlugin, &["gatsby"]),
1314        ];
1315        for (plugin, expected_enablers) in cases {
1316            let enablers = plugin.enablers();
1317            for expected in expected_enablers {
1318                assert!(
1319                    enablers.contains(expected),
1320                    "plugin '{}' should have '{}'",
1321                    plugin.name(),
1322                    expected
1323                );
1324            }
1325        }
1326    }
1327
1328    #[test]
1329    fn testing_plugins_enablers() {
1330        let cases: Vec<(&dyn Plugin, &str)> = vec![
1331            (&jest::JestPlugin, "jest"),
1332            (&vitest::VitestPlugin, "vitest"),
1333            (&playwright::PlaywrightPlugin, "@playwright/test"),
1334            (&cypress::CypressPlugin, "cypress"),
1335            (&mocha::MochaPlugin, "mocha"),
1336        ];
1337        for (plugin, enabler) in cases {
1338            assert!(
1339                plugin.enablers().contains(&enabler),
1340                "plugin '{}' should have '{}'",
1341                plugin.name(),
1342                enabler
1343            );
1344        }
1345    }
1346
1347    #[test]
1348    fn bundler_plugins_enablers() {
1349        let cases: Vec<(&dyn Plugin, &str)> = vec![
1350            (&vite::VitePlugin, "vite"),
1351            (&webpack::WebpackPlugin, "webpack"),
1352            (&rollup::RollupPlugin, "rollup"),
1353        ];
1354        for (plugin, enabler) in cases {
1355            assert!(
1356                plugin.enablers().contains(&enabler),
1357                "plugin '{}' should have '{}'",
1358                plugin.name(),
1359                enabler
1360            );
1361        }
1362    }
1363
1364    #[test]
1365    fn test_plugins_have_test_entry_patterns() {
1366        let test_plugins: Vec<&dyn Plugin> = vec![
1367            &jest::JestPlugin,
1368            &vitest::VitestPlugin,
1369            &mocha::MochaPlugin,
1370        ];
1371        for plugin in test_plugins {
1372            let patterns = plugin.entry_patterns();
1373            assert!(
1374                !patterns.is_empty(),
1375                "test plugin '{}' should have entry patterns",
1376                plugin.name()
1377            );
1378            assert!(
1379                patterns
1380                    .iter()
1381                    .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
1382                "test plugin '{}' should have test/spec patterns",
1383                plugin.name()
1384            );
1385        }
1386    }
1387
1388    #[test]
1389    fn framework_plugins_have_entry_patterns() {
1390        let plugins: Vec<&dyn Plugin> = vec![
1391            &nextjs::NextJsPlugin,
1392            &nuxt::NuxtPlugin,
1393            &angular::AngularPlugin,
1394            &sveltekit::SvelteKitPlugin,
1395        ];
1396        for plugin in plugins {
1397            assert!(
1398                !plugin.entry_patterns().is_empty(),
1399                "framework plugin '{}' should have entry patterns",
1400                plugin.name()
1401            );
1402        }
1403    }
1404
1405    #[test]
1406    fn plugins_with_resolve_config_have_config_patterns() {
1407        let plugins: Vec<&dyn Plugin> = vec![
1408            &jest::JestPlugin,
1409            &vitest::VitestPlugin,
1410            &babel::BabelPlugin,
1411            &eslint::EslintPlugin,
1412            &webpack::WebpackPlugin,
1413            &storybook::StorybookPlugin,
1414            &typescript::TypeScriptPlugin,
1415            &postcss::PostCssPlugin,
1416            &nextjs::NextJsPlugin,
1417            &nuxt::NuxtPlugin,
1418            &angular::AngularPlugin,
1419            &nx::NxPlugin,
1420            &rollup::RollupPlugin,
1421            &sveltekit::SvelteKitPlugin,
1422            &prettier::PrettierPlugin,
1423        ];
1424        for plugin in plugins {
1425            assert!(
1426                !plugin.config_patterns().is_empty(),
1427                "plugin '{}' with resolve_config should have config_patterns",
1428                plugin.name()
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn plugin_tooling_deps_include_enabler_package() {
1435        let plugins: Vec<&dyn Plugin> = vec![
1436            &jest::JestPlugin,
1437            &vitest::VitestPlugin,
1438            &webpack::WebpackPlugin,
1439            &typescript::TypeScriptPlugin,
1440            &eslint::EslintPlugin,
1441            &prettier::PrettierPlugin,
1442        ];
1443        for plugin in plugins {
1444            let tooling = plugin.tooling_dependencies();
1445            let enablers = plugin.enablers();
1446            assert!(
1447                enablers
1448                    .iter()
1449                    .any(|e| !e.ends_with('/') && tooling.contains(e)),
1450                "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
1451                plugin.name()
1452            );
1453        }
1454    }
1455
1456    #[test]
1457    fn nextjs_has_used_exports_for_pages() {
1458        let plugin = nextjs::NextJsPlugin;
1459        let exports = plugin.used_exports();
1460        assert!(!exports.is_empty());
1461        assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
1462    }
1463
1464    #[test]
1465    fn remix_has_used_exports_for_routes() {
1466        let plugin = remix::RemixPlugin;
1467        let exports = plugin.used_exports();
1468        assert!(!exports.is_empty());
1469        let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
1470        assert!(route_entry.is_some());
1471        let (_, names) = route_entry.unwrap();
1472        assert!(names.contains(&"loader"));
1473        assert!(names.contains(&"action"));
1474        assert!(names.contains(&"default"));
1475    }
1476
1477    #[test]
1478    fn sveltekit_has_used_exports_for_routes() {
1479        let plugin = sveltekit::SvelteKitPlugin;
1480        let exports = plugin.used_exports();
1481        assert!(!exports.is_empty());
1482        assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
1483    }
1484
1485    #[test]
1486    fn nuxt_has_hash_virtual_prefix() {
1487        assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
1488    }
1489
1490    #[test]
1491    fn sveltekit_has_dollar_virtual_prefixes() {
1492        let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
1493        assert!(prefixes.contains(&"$app/"));
1494        assert!(prefixes.contains(&"$env/"));
1495        assert!(prefixes.contains(&"$lib/"));
1496    }
1497
1498    #[test]
1499    fn sveltekit_has_lib_path_alias() {
1500        let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
1501        assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
1502    }
1503
1504    #[test]
1505    fn nuxt_has_tilde_path_alias() {
1506        let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
1507        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
1508        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
1509    }
1510
1511    #[test]
1512    fn jest_has_package_json_config_key() {
1513        assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
1514    }
1515
1516    #[test]
1517    fn babel_has_package_json_config_key() {
1518        assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
1519    }
1520
1521    #[test]
1522    fn eslint_has_package_json_config_key() {
1523        assert_eq!(
1524            eslint::EslintPlugin.package_json_config_key(),
1525            Some("eslintConfig")
1526        );
1527    }
1528
1529    #[test]
1530    fn prettier_has_package_json_config_key() {
1531        assert_eq!(
1532            prettier::PrettierPlugin.package_json_config_key(),
1533            Some("prettier")
1534        );
1535    }
1536
1537    #[test]
1538    fn macro_generated_plugin_basic_properties() {
1539        let plugin = msw::MswPlugin;
1540        assert_eq!(plugin.name(), "msw");
1541        assert!(plugin.enablers().contains(&"msw"));
1542        assert!(!plugin.entry_patterns().is_empty());
1543        assert!(plugin.config_patterns().is_empty());
1544        assert!(!plugin.always_used().is_empty());
1545        assert!(!plugin.tooling_dependencies().is_empty());
1546    }
1547
1548    #[test]
1549    fn macro_generated_plugin_with_used_exports() {
1550        let plugin = remix::RemixPlugin;
1551        assert_eq!(plugin.name(), "remix");
1552        assert!(!plugin.used_exports().is_empty());
1553    }
1554
1555    #[test]
1556    fn macro_generated_plugin_imports_only_resolve_config() {
1557        let plugin = cypress::CypressPlugin;
1558        let source = r"
1559            import { defineConfig } from 'cypress';
1560            import coveragePlugin from '@cypress/code-coverage';
1561            export default defineConfig({});
1562        ";
1563        let result = plugin.resolve_config(
1564            Path::new("cypress.config.ts"),
1565            source,
1566            Path::new("/project"),
1567        );
1568        assert!(
1569            result
1570                .referenced_dependencies
1571                .contains(&"cypress".to_string())
1572        );
1573        assert!(
1574            result
1575                .referenced_dependencies
1576                .contains(&"@cypress/code-coverage".to_string())
1577        );
1578    }
1579
1580    #[test]
1581    fn builtin_plugin_count_is_expected() {
1582        let plugins = registry::builtin::create_builtin_plugins();
1583        assert!(
1584            plugins.len() >= 80,
1585            "expected at least 80 built-in plugins, got {}",
1586            plugins.len()
1587        );
1588    }
1589}