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