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