Skip to main content

fallow_core/plugins/
mod.rs

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