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