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    /// Glob patterns for test fixture files consumed by this framework.
634    /// These files are implicitly used by the test runner and should not be
635    /// flagged as unused. Unlike `always_used()`, this carries semantic intent
636    /// for reporting purposes.
637    fn fixture_glob_patterns(&self) -> &'static [&'static str] {
638        &[]
639    }
640
641    /// Dependencies that are tooling (used via CLI/config, not source imports).
642    /// These should not be flagged as unused devDependencies.
643    fn tooling_dependencies(&self) -> &'static [&'static str] {
644        &[]
645    }
646
647    /// Import prefixes that are virtual modules provided by this framework at build time.
648    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
649    /// Each entry is matched as a prefix against the extracted package name
650    /// (e.g., `"@theme/"` matches `@theme/Layout`).
651    fn virtual_module_prefixes(&self) -> &'static [&'static str] {
652        &[]
653    }
654
655    /// Package name suffixes that are virtual modules provided by this framework
656    /// at build time (e.g., test runner mock conventions).
657    /// Imports matching these suffixes should not be flagged as unlisted dependencies.
658    /// Each entry is matched as a suffix against the extracted package name
659    /// (e.g., `"/__mocks__"` matches `@aws-sdk/__mocks__` and `some-pkg/__mocks__`).
660    fn virtual_package_suffixes(&self) -> &'static [&'static str] {
661        &[]
662    }
663
664    /// Import suffixes for build-time generated relative imports.
665    ///
666    /// Unresolved relative imports whose specifier ends with one of these suffixes
667    /// will not be flagged as unresolved. For example, SvelteKit generates
668    /// `./$types` imports in route files — returning `"/$types"` suppresses those.
669    fn generated_import_patterns(&self) -> &'static [&'static str] {
670        &[]
671    }
672
673    /// Path alias mappings provided by this framework at build time.
674    ///
675    /// Returns a list of `(prefix, replacement_dir)` tuples. When an import starting
676    /// with `prefix` fails to resolve, the resolver will substitute the prefix with
677    /// `replacement_dir` (relative to the project root) and retry.
678    ///
679    /// Called once when plugins are activated. The project `root` is provided so
680    /// plugins can inspect the filesystem (e.g., Nuxt checks whether `app/` exists
681    /// to determine the `srcDir`).
682    fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
683        vec![]
684    }
685
686    /// Parse a config file's AST to discover additional entries, dependencies, etc.
687    ///
688    /// Called for each config file matching `config_patterns()`. The source code
689    /// and parsed AST are provided — use [`config_parser`] utilities to extract values.
690    fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
691        PluginResult::default()
692    }
693
694    /// The key name in package.json that holds inline configuration for this tool.
695    /// When set (e.g., `"jest"` for the `"jest"` key in package.json), the plugin
696    /// system will extract that key's value and call `resolve_config` with its
697    /// JSON content if no standalone config file was found.
698    fn package_json_config_key(&self) -> Option<&'static str> {
699        None
700    }
701}
702
703fn builtin_entry_point_role(name: &str) -> EntryPointRole {
704    if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
705        EntryPointRole::Test
706    } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
707        EntryPointRole::Runtime
708    } else {
709        EntryPointRole::Support
710    }
711}
712
713/// Macro to eliminate boilerplate in plugin implementations.
714///
715/// Generates a struct and a `Plugin` trait impl with the standard static methods
716/// (`name`, `enablers`, `entry_patterns`, `config_patterns`, `always_used`, `tooling_dependencies`,
717/// `fixture_glob_patterns`, `used_exports`).
718///
719/// For plugins that need custom `resolve_config()` or `is_enabled()`, keep those as
720/// manual `impl Plugin for ...` blocks instead of using this macro.
721///
722/// # Usage
723///
724/// ```ignore
725/// // Simple plugin (most common):
726/// define_plugin! {
727///     struct VitePlugin => "vite",
728///     enablers: ENABLERS,
729///     entry_patterns: ENTRY_PATTERNS,
730///     config_patterns: CONFIG_PATTERNS,
731///     always_used: ALWAYS_USED,
732///     tooling_dependencies: TOOLING_DEPENDENCIES,
733/// }
734///
735/// // Plugin with used_exports:
736/// define_plugin! {
737///     struct RemixPlugin => "remix",
738///     enablers: ENABLERS,
739///     entry_patterns: ENTRY_PATTERNS,
740///     always_used: ALWAYS_USED,
741///     tooling_dependencies: TOOLING_DEPENDENCIES,
742///     used_exports: [("app/routes/**/*.{ts,tsx}", ROUTE_EXPORTS)],
743/// }
744///
745/// // Plugin with imports-only resolve_config (extracts imports from config as deps):
746/// define_plugin! {
747///     struct CypressPlugin => "cypress",
748///     enablers: ENABLERS,
749///     entry_patterns: ENTRY_PATTERNS,
750///     config_patterns: CONFIG_PATTERNS,
751///     always_used: ALWAYS_USED,
752///     tooling_dependencies: TOOLING_DEPENDENCIES,
753///     resolve_config: imports_only,
754/// }
755///
756/// // Plugin with custom resolve_config body:
757/// define_plugin! {
758///     struct RollupPlugin => "rollup",
759///     enablers: ENABLERS,
760///     config_patterns: CONFIG_PATTERNS,
761///     always_used: ALWAYS_USED,
762///     tooling_dependencies: TOOLING_DEPENDENCIES,
763///     resolve_config(config_path, source, _root) {
764///         let mut result = PluginResult::default();
765///         // custom config parsing...
766///         result
767///     }
768/// }
769/// ```
770///
771/// All fields except `struct` and `enablers` are optional and default to `&[]` / `vec![]`.
772macro_rules! define_plugin {
773    // Variant with `resolve_config: imports_only`: generates a resolve_config method
774    // that extracts imports from config files and registers them as referenced dependencies.
775    (
776        struct $name:ident => $display:expr,
777        enablers: $enablers:expr
778        $(, entry_patterns: $entry:expr)?
779        $(, config_patterns: $config:expr)?
780        $(, always_used: $always:expr)?
781        $(, tooling_dependencies: $tooling:expr)?
782        $(, fixture_glob_patterns: $fixtures:expr)?
783        $(, virtual_module_prefixes: $virtual:expr)?
784        $(, virtual_package_suffixes: $virtual_suffixes:expr)?
785        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
786        , resolve_config: imports_only
787        $(,)?
788    ) => {
789        pub struct $name;
790
791        impl Plugin for $name {
792            fn name(&self) -> &'static str {
793                $display
794            }
795
796            fn enablers(&self) -> &'static [&'static str] {
797                $enablers
798            }
799
800            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
801            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
802            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
803            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
804            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
805            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
806            $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
807
808            $(
809                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
810                    vec![$( ($pat, $exports) ),*]
811                }
812            )?
813
814            fn resolve_config(
815                &self,
816                config_path: &std::path::Path,
817                source: &str,
818                _root: &std::path::Path,
819            ) -> PluginResult {
820                let mut result = PluginResult::default();
821                let imports = crate::plugins::config_parser::extract_imports(source, config_path);
822                for imp in &imports {
823                    let dep = crate::resolve::extract_package_name(imp);
824                    result.referenced_dependencies.push(dep);
825                }
826                result
827            }
828        }
829    };
830
831    // Variant with custom resolve_config body: generates a resolve_config method
832    // with the caller-supplied block. Parameter names are caller-controlled (use
833    // `_root` for unused params to satisfy clippy).
834    (
835        struct $name:ident => $display:expr,
836        enablers: $enablers:expr
837        $(, entry_patterns: $entry:expr)?
838        $(, config_patterns: $config:expr)?
839        $(, always_used: $always:expr)?
840        $(, tooling_dependencies: $tooling:expr)?
841        $(, fixture_glob_patterns: $fixtures:expr)?
842        $(, virtual_module_prefixes: $virtual:expr)?
843        $(, virtual_package_suffixes: $virtual_suffixes:expr)?
844        $(, package_json_config_key: $pkg_key:expr)?
845        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
846        , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
847        $(,)?
848    ) => {
849        pub struct $name;
850
851        impl Plugin for $name {
852            fn name(&self) -> &'static str {
853                $display
854            }
855
856            fn enablers(&self) -> &'static [&'static str] {
857                $enablers
858            }
859
860            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
861            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
862            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
863            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
864            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
865            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
866            $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
867
868            $(
869                fn package_json_config_key(&self) -> Option<&'static str> {
870                    Some($pkg_key)
871                }
872            )?
873
874            $(
875                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
876                    vec![$( ($pat, $exports) ),*]
877                }
878            )?
879
880            fn resolve_config(
881                &self,
882                $cp: &std::path::Path,
883                $src: &str,
884                $root: &std::path::Path,
885            ) -> PluginResult
886            $body
887        }
888    };
889
890    // Base variant: no resolve_config.
891    (
892        struct $name:ident => $display:expr,
893        enablers: $enablers:expr
894        $(, entry_patterns: $entry:expr)?
895        $(, config_patterns: $config:expr)?
896        $(, always_used: $always:expr)?
897        $(, tooling_dependencies: $tooling:expr)?
898        $(, fixture_glob_patterns: $fixtures:expr)?
899        $(, virtual_module_prefixes: $virtual:expr)?
900        $(, virtual_package_suffixes: $virtual_suffixes:expr)?
901        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
902        $(,)?
903    ) => {
904        pub struct $name;
905
906        impl Plugin for $name {
907            fn name(&self) -> &'static str {
908                $display
909            }
910
911            fn enablers(&self) -> &'static [&'static str] {
912                $enablers
913            }
914
915            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
916            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
917            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
918            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
919            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
920            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
921            $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
922
923            $(
924                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
925                    vec![$( ($pat, $exports) ),*]
926                }
927            )?
928        }
929    };
930}
931
932pub mod config_parser;
933pub mod registry;
934mod tooling;
935
936pub use registry::{AggregatedPluginResult, PluginRegistry};
937pub use tooling::is_known_tooling_dependency;
938
939mod angular;
940mod astro;
941mod ava;
942mod babel;
943mod biome;
944mod bun;
945mod c8;
946mod capacitor;
947mod changesets;
948mod commitizen;
949mod commitlint;
950mod convex;
951mod cspell;
952mod cucumber;
953mod cypress;
954mod dependency_cruiser;
955mod docusaurus;
956mod drizzle;
957mod electron;
958mod eslint;
959mod expo;
960mod expo_router;
961mod gatsby;
962mod graphql_codegen;
963mod hardhat;
964mod husky;
965mod i18next;
966mod jest;
967mod karma;
968mod knex;
969mod kysely;
970mod lefthook;
971mod lint_staged;
972mod markdownlint;
973mod mocha;
974mod msw;
975mod nestjs;
976mod next_intl;
977mod nextjs;
978mod nitro;
979mod nodemon;
980mod nuxt;
981mod nx;
982mod nyc;
983mod openapi_ts;
984mod oxlint;
985mod pandacss;
986mod parcel;
987mod playwright;
988mod plop;
989mod pm2;
990mod pnpm;
991mod postcss;
992mod prettier;
993mod prisma;
994mod qwik;
995mod react_native;
996mod react_router;
997mod relay;
998mod remark;
999mod remix;
1000mod rolldown;
1001mod rollup;
1002mod rsbuild;
1003mod rspack;
1004mod sanity;
1005mod semantic_release;
1006mod sentry;
1007mod simple_git_hooks;
1008mod storybook;
1009mod stylelint;
1010mod sveltekit;
1011mod svgo;
1012mod svgr;
1013mod swc;
1014mod syncpack;
1015mod tailwind;
1016mod tanstack_router;
1017mod tap;
1018mod tsd;
1019mod tsdown;
1020mod tsup;
1021mod turborepo;
1022mod typedoc;
1023mod typeorm;
1024mod typescript;
1025mod unocss;
1026mod vite;
1027mod vitepress;
1028mod vitest;
1029mod webdriverio;
1030mod webpack;
1031mod wrangler;
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036    use std::path::Path;
1037
1038    // ── is_enabled_with_deps edge cases ──────────────────────────
1039
1040    #[test]
1041    fn is_enabled_with_deps_exact_match() {
1042        let plugin = nextjs::NextJsPlugin;
1043        let deps = vec!["next".to_string()];
1044        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1045    }
1046
1047    #[test]
1048    fn is_enabled_with_deps_no_match() {
1049        let plugin = nextjs::NextJsPlugin;
1050        let deps = vec!["react".to_string()];
1051        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1052    }
1053
1054    #[test]
1055    fn is_enabled_with_deps_empty_deps() {
1056        let plugin = nextjs::NextJsPlugin;
1057        let deps: Vec<String> = vec![];
1058        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1059    }
1060
1061    #[test]
1062    fn entry_point_role_defaults_are_centralized() {
1063        assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
1064        assert_eq!(
1065            vitest::VitestPlugin.entry_point_role(),
1066            EntryPointRole::Test
1067        );
1068        assert_eq!(
1069            storybook::StorybookPlugin.entry_point_role(),
1070            EntryPointRole::Support
1071        );
1072        assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1073    }
1074
1075    #[test]
1076    fn plugins_with_entry_patterns_have_explicit_role_intent() {
1077        let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1078            TEST_ENTRY_POINT_PLUGINS
1079                .iter()
1080                .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1081                .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1082                .copied()
1083                .collect();
1084
1085        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1086            if plugin.entry_patterns().is_empty() {
1087                continue;
1088            }
1089            assert!(
1090                runtime_or_test_or_support.contains(plugin.name()),
1091                "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1092                plugin.name()
1093            );
1094        }
1095    }
1096
1097    // ── PluginResult::is_empty ───────────────────────────────────
1098
1099    #[test]
1100    fn plugin_result_is_empty_when_default() {
1101        let r = PluginResult::default();
1102        assert!(r.is_empty());
1103    }
1104
1105    #[test]
1106    fn plugin_result_not_empty_with_entry_patterns() {
1107        let r = PluginResult {
1108            entry_patterns: vec!["*.ts".into()],
1109            ..Default::default()
1110        };
1111        assert!(!r.is_empty());
1112    }
1113
1114    #[test]
1115    fn plugin_result_not_empty_with_referenced_deps() {
1116        let r = PluginResult {
1117            referenced_dependencies: vec!["lodash".to_string()],
1118            ..Default::default()
1119        };
1120        assert!(!r.is_empty());
1121    }
1122
1123    #[test]
1124    fn plugin_result_not_empty_with_setup_files() {
1125        let r = PluginResult {
1126            setup_files: vec![PathBuf::from("/setup.ts")],
1127            ..Default::default()
1128        };
1129        assert!(!r.is_empty());
1130    }
1131
1132    #[test]
1133    fn plugin_result_not_empty_with_always_used_files() {
1134        let r = PluginResult {
1135            always_used_files: vec!["**/*.stories.tsx".to_string()],
1136            ..Default::default()
1137        };
1138        assert!(!r.is_empty());
1139    }
1140
1141    #[test]
1142    fn plugin_result_not_empty_with_fixture_patterns() {
1143        let r = PluginResult {
1144            fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
1145            ..Default::default()
1146        };
1147        assert!(!r.is_empty());
1148    }
1149
1150    // ── is_enabled_with_deps prefix matching ─────────────────────
1151
1152    #[test]
1153    fn is_enabled_with_deps_prefix_match() {
1154        // Storybook plugin uses prefix enabler "@storybook/"
1155        let plugin = storybook::StorybookPlugin;
1156        let deps = vec!["@storybook/react".to_string()];
1157        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1158    }
1159
1160    #[test]
1161    fn is_enabled_with_deps_prefix_no_match_without_slash() {
1162        // "@storybook/" prefix should NOT match "@storybookish" (different package)
1163        let plugin = storybook::StorybookPlugin;
1164        let deps = vec!["@storybookish".to_string()];
1165        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1166    }
1167
1168    #[test]
1169    fn is_enabled_with_deps_multiple_enablers() {
1170        // Vitest plugin has multiple enablers
1171        let plugin = vitest::VitestPlugin;
1172        let deps_vitest = vec!["vitest".to_string()];
1173        let deps_none = vec!["mocha".to_string()];
1174        assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
1175        assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
1176    }
1177
1178    // ── Plugin trait default implementations ─────────────────────
1179
1180    #[test]
1181    fn plugin_default_methods_return_empty() {
1182        // Use a simple plugin to test default trait methods
1183        let plugin = commitizen::CommitizenPlugin;
1184        assert!(
1185            plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
1186        );
1187        assert!(plugin.virtual_module_prefixes().is_empty());
1188        assert!(plugin.virtual_package_suffixes().is_empty());
1189        assert!(plugin.path_aliases(Path::new("/project")).is_empty());
1190        assert!(
1191            plugin.package_json_config_key().is_none()
1192                || plugin.package_json_config_key().is_some()
1193        );
1194    }
1195
1196    #[test]
1197    fn plugin_resolve_config_default_returns_empty() {
1198        let plugin = commitizen::CommitizenPlugin;
1199        let result = plugin.resolve_config(
1200            Path::new("/project/config.js"),
1201            "const x = 1;",
1202            Path::new("/project"),
1203        );
1204        assert!(result.is_empty());
1205    }
1206
1207    // ── is_enabled_with_deps exact and prefix ────────────────────
1208
1209    #[test]
1210    fn is_enabled_with_deps_exact_and_prefix_both_work() {
1211        let plugin = storybook::StorybookPlugin;
1212        let deps_exact = vec!["storybook".to_string()];
1213        assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
1214        let deps_prefix = vec!["@storybook/vue3".to_string()];
1215        assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
1216    }
1217
1218    #[test]
1219    fn is_enabled_with_deps_multiple_enablers_remix() {
1220        let plugin = remix::RemixPlugin;
1221        let deps_node = vec!["@remix-run/node".to_string()];
1222        assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
1223        let deps_react = vec!["@remix-run/react".to_string()];
1224        assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
1225        let deps_cf = vec!["@remix-run/cloudflare".to_string()];
1226        assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
1227    }
1228
1229    // ── Plugin trait default implementations ──────────────────────
1230
1231    struct MinimalPlugin;
1232    impl Plugin for MinimalPlugin {
1233        fn name(&self) -> &'static str {
1234            "minimal"
1235        }
1236    }
1237
1238    #[test]
1239    fn default_enablers_is_empty() {
1240        assert!(MinimalPlugin.enablers().is_empty());
1241    }
1242
1243    #[test]
1244    fn default_entry_patterns_is_empty() {
1245        assert!(MinimalPlugin.entry_patterns().is_empty());
1246    }
1247
1248    #[test]
1249    fn default_config_patterns_is_empty() {
1250        assert!(MinimalPlugin.config_patterns().is_empty());
1251    }
1252
1253    #[test]
1254    fn default_always_used_is_empty() {
1255        assert!(MinimalPlugin.always_used().is_empty());
1256    }
1257
1258    #[test]
1259    fn default_used_exports_is_empty() {
1260        assert!(MinimalPlugin.used_exports().is_empty());
1261    }
1262
1263    #[test]
1264    fn default_tooling_dependencies_is_empty() {
1265        assert!(MinimalPlugin.tooling_dependencies().is_empty());
1266    }
1267
1268    #[test]
1269    fn default_fixture_glob_patterns_is_empty() {
1270        assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
1271    }
1272
1273    #[test]
1274    fn default_virtual_module_prefixes_is_empty() {
1275        assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
1276    }
1277
1278    #[test]
1279    fn default_virtual_package_suffixes_is_empty() {
1280        assert!(MinimalPlugin.virtual_package_suffixes().is_empty());
1281    }
1282
1283    #[test]
1284    fn default_path_aliases_is_empty() {
1285        assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
1286    }
1287
1288    #[test]
1289    fn default_resolve_config_returns_empty() {
1290        let r = MinimalPlugin.resolve_config(
1291            Path::new("config.js"),
1292            "export default {}",
1293            Path::new("/"),
1294        );
1295        assert!(r.is_empty());
1296    }
1297
1298    #[test]
1299    fn default_package_json_config_key_is_none() {
1300        assert!(MinimalPlugin.package_json_config_key().is_none());
1301    }
1302
1303    #[test]
1304    fn default_is_enabled_returns_false_when_no_enablers() {
1305        let deps = vec!["anything".to_string()];
1306        assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
1307    }
1308
1309    // ── All built-in plugins have unique names ───────────────────
1310
1311    #[test]
1312    fn all_builtin_plugin_names_are_unique() {
1313        let plugins = registry::builtin::create_builtin_plugins();
1314        let mut seen = std::collections::BTreeSet::new();
1315        for p in &plugins {
1316            let name = p.name();
1317            assert!(seen.insert(name), "duplicate plugin name: {name}");
1318        }
1319    }
1320
1321    #[test]
1322    fn all_builtin_plugins_have_enablers() {
1323        let plugins = registry::builtin::create_builtin_plugins();
1324        for p in &plugins {
1325            assert!(
1326                !p.enablers().is_empty(),
1327                "plugin '{}' has no enablers",
1328                p.name()
1329            );
1330        }
1331    }
1332
1333    #[test]
1334    fn plugins_with_config_patterns_have_always_used() {
1335        let plugins = registry::builtin::create_builtin_plugins();
1336        for p in &plugins {
1337            if !p.config_patterns().is_empty() {
1338                assert!(
1339                    !p.always_used().is_empty(),
1340                    "plugin '{}' has config_patterns but no always_used",
1341                    p.name()
1342                );
1343            }
1344        }
1345    }
1346
1347    // ── Enabler patterns for all categories ──────────────────────
1348
1349    #[test]
1350    fn framework_plugins_enablers() {
1351        let cases: Vec<(&dyn Plugin, &[&str])> = vec![
1352            (&nextjs::NextJsPlugin, &["next"]),
1353            (&nuxt::NuxtPlugin, &["nuxt"]),
1354            (&angular::AngularPlugin, &["@angular/core"]),
1355            (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
1356            (&gatsby::GatsbyPlugin, &["gatsby"]),
1357        ];
1358        for (plugin, expected_enablers) in cases {
1359            let enablers = plugin.enablers();
1360            for expected in expected_enablers {
1361                assert!(
1362                    enablers.contains(expected),
1363                    "plugin '{}' should have '{}'",
1364                    plugin.name(),
1365                    expected
1366                );
1367            }
1368        }
1369    }
1370
1371    #[test]
1372    fn testing_plugins_enablers() {
1373        let cases: Vec<(&dyn Plugin, &str)> = vec![
1374            (&jest::JestPlugin, "jest"),
1375            (&vitest::VitestPlugin, "vitest"),
1376            (&playwright::PlaywrightPlugin, "@playwright/test"),
1377            (&cypress::CypressPlugin, "cypress"),
1378            (&mocha::MochaPlugin, "mocha"),
1379        ];
1380        for (plugin, enabler) in cases {
1381            assert!(
1382                plugin.enablers().contains(&enabler),
1383                "plugin '{}' should have '{}'",
1384                plugin.name(),
1385                enabler
1386            );
1387        }
1388    }
1389
1390    #[test]
1391    fn bundler_plugins_enablers() {
1392        let cases: Vec<(&dyn Plugin, &str)> = vec![
1393            (&vite::VitePlugin, "vite"),
1394            (&webpack::WebpackPlugin, "webpack"),
1395            (&rollup::RollupPlugin, "rollup"),
1396        ];
1397        for (plugin, enabler) in cases {
1398            assert!(
1399                plugin.enablers().contains(&enabler),
1400                "plugin '{}' should have '{}'",
1401                plugin.name(),
1402                enabler
1403            );
1404        }
1405    }
1406
1407    #[test]
1408    fn test_plugins_have_test_entry_patterns() {
1409        let test_plugins: Vec<&dyn Plugin> = vec![
1410            &jest::JestPlugin,
1411            &vitest::VitestPlugin,
1412            &mocha::MochaPlugin,
1413            &tap::TapPlugin,
1414            &tsd::TsdPlugin,
1415        ];
1416        for plugin in test_plugins {
1417            let patterns = plugin.entry_patterns();
1418            assert!(
1419                !patterns.is_empty(),
1420                "test plugin '{}' should have entry patterns",
1421                plugin.name()
1422            );
1423            assert!(
1424                patterns
1425                    .iter()
1426                    .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
1427                "test plugin '{}' should have test/spec patterns",
1428                plugin.name()
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn framework_plugins_have_entry_patterns() {
1435        let plugins: Vec<&dyn Plugin> = vec![
1436            &nextjs::NextJsPlugin,
1437            &nuxt::NuxtPlugin,
1438            &angular::AngularPlugin,
1439            &sveltekit::SvelteKitPlugin,
1440        ];
1441        for plugin in plugins {
1442            assert!(
1443                !plugin.entry_patterns().is_empty(),
1444                "framework plugin '{}' should have entry patterns",
1445                plugin.name()
1446            );
1447        }
1448    }
1449
1450    #[test]
1451    fn plugins_with_resolve_config_have_config_patterns() {
1452        let plugins: Vec<&dyn Plugin> = vec![
1453            &jest::JestPlugin,
1454            &vitest::VitestPlugin,
1455            &babel::BabelPlugin,
1456            &eslint::EslintPlugin,
1457            &webpack::WebpackPlugin,
1458            &storybook::StorybookPlugin,
1459            &typescript::TypeScriptPlugin,
1460            &postcss::PostCssPlugin,
1461            &nextjs::NextJsPlugin,
1462            &nuxt::NuxtPlugin,
1463            &angular::AngularPlugin,
1464            &nx::NxPlugin,
1465            &rollup::RollupPlugin,
1466            &sveltekit::SvelteKitPlugin,
1467            &prettier::PrettierPlugin,
1468        ];
1469        for plugin in plugins {
1470            assert!(
1471                !plugin.config_patterns().is_empty(),
1472                "plugin '{}' with resolve_config should have config_patterns",
1473                plugin.name()
1474            );
1475        }
1476    }
1477
1478    #[test]
1479    fn plugin_tooling_deps_include_enabler_package() {
1480        let plugins: Vec<&dyn Plugin> = vec![
1481            &jest::JestPlugin,
1482            &vitest::VitestPlugin,
1483            &webpack::WebpackPlugin,
1484            &typescript::TypeScriptPlugin,
1485            &eslint::EslintPlugin,
1486            &prettier::PrettierPlugin,
1487        ];
1488        for plugin in plugins {
1489            let tooling = plugin.tooling_dependencies();
1490            let enablers = plugin.enablers();
1491            assert!(
1492                enablers
1493                    .iter()
1494                    .any(|e| !e.ends_with('/') && tooling.contains(e)),
1495                "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
1496                plugin.name()
1497            );
1498        }
1499    }
1500
1501    #[test]
1502    fn nextjs_has_used_exports_for_pages() {
1503        let plugin = nextjs::NextJsPlugin;
1504        let exports = plugin.used_exports();
1505        assert!(!exports.is_empty());
1506        assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
1507    }
1508
1509    #[test]
1510    fn remix_has_used_exports_for_routes() {
1511        let plugin = remix::RemixPlugin;
1512        let exports = plugin.used_exports();
1513        assert!(!exports.is_empty());
1514        let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
1515        assert!(route_entry.is_some());
1516        let (_, names) = route_entry.unwrap();
1517        assert!(names.contains(&"loader"));
1518        assert!(names.contains(&"action"));
1519        assert!(names.contains(&"default"));
1520    }
1521
1522    #[test]
1523    fn sveltekit_has_used_exports_for_routes() {
1524        let plugin = sveltekit::SvelteKitPlugin;
1525        let exports = plugin.used_exports();
1526        assert!(!exports.is_empty());
1527        assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
1528    }
1529
1530    #[test]
1531    fn nuxt_has_hash_virtual_prefix() {
1532        assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
1533    }
1534
1535    #[test]
1536    fn sveltekit_has_dollar_virtual_prefixes() {
1537        let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
1538        assert!(prefixes.contains(&"$app/"));
1539        assert!(prefixes.contains(&"$env/"));
1540        assert!(prefixes.contains(&"$lib/"));
1541    }
1542
1543    #[test]
1544    fn sveltekit_has_lib_path_alias() {
1545        let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
1546        assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
1547    }
1548
1549    #[test]
1550    fn nuxt_has_tilde_path_alias() {
1551        let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
1552        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
1553        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
1554    }
1555
1556    #[test]
1557    fn jest_has_package_json_config_key() {
1558        assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
1559    }
1560
1561    #[test]
1562    fn tsd_has_package_json_config_key() {
1563        assert_eq!(tsd::TsdPlugin.package_json_config_key(), Some("tsd"));
1564    }
1565
1566    #[test]
1567    fn babel_has_package_json_config_key() {
1568        assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
1569    }
1570
1571    #[test]
1572    fn eslint_has_package_json_config_key() {
1573        assert_eq!(
1574            eslint::EslintPlugin.package_json_config_key(),
1575            Some("eslintConfig")
1576        );
1577    }
1578
1579    #[test]
1580    fn prettier_has_package_json_config_key() {
1581        assert_eq!(
1582            prettier::PrettierPlugin.package_json_config_key(),
1583            Some("prettier")
1584        );
1585    }
1586
1587    #[test]
1588    fn macro_generated_plugin_basic_properties() {
1589        let plugin = msw::MswPlugin;
1590        assert_eq!(plugin.name(), "msw");
1591        assert!(plugin.enablers().contains(&"msw"));
1592        assert!(!plugin.entry_patterns().is_empty());
1593        assert!(plugin.config_patterns().is_empty());
1594        assert!(!plugin.always_used().is_empty());
1595        assert!(!plugin.tooling_dependencies().is_empty());
1596    }
1597
1598    #[test]
1599    fn macro_generated_plugin_with_used_exports() {
1600        let plugin = remix::RemixPlugin;
1601        assert_eq!(plugin.name(), "remix");
1602        assert!(!plugin.used_exports().is_empty());
1603    }
1604
1605    #[test]
1606    fn macro_passes_through_virtual_package_suffixes() {
1607        // Synthetic smoke check: a plugin defined via define_plugin! that
1608        // declares virtual_package_suffixes returns those suffixes from the
1609        // trait method. Guards against future macro regressions where the
1610        // field name silently drops out of one of the three variants.
1611        define_plugin! {
1612            struct MacroSuffixSmokePlugin => "macro-suffix-smoke",
1613            enablers: &["macro-suffix-smoke"],
1614            virtual_package_suffixes: &["/__macro_smoke__"],
1615        }
1616
1617        let plugin = MacroSuffixSmokePlugin;
1618        assert_eq!(
1619            plugin.virtual_package_suffixes(),
1620            &["/__macro_smoke__"],
1621            "macro-declared virtual_package_suffixes must propagate to the trait method"
1622        );
1623    }
1624
1625    #[test]
1626    fn macro_generated_plugin_imports_only_resolve_config() {
1627        let plugin = cypress::CypressPlugin;
1628        let source = r"
1629            import { defineConfig } from 'cypress';
1630            import coveragePlugin from '@cypress/code-coverage';
1631            export default defineConfig({});
1632        ";
1633        let result = plugin.resolve_config(
1634            Path::new("cypress.config.ts"),
1635            source,
1636            Path::new("/project"),
1637        );
1638        assert!(
1639            result
1640                .referenced_dependencies
1641                .contains(&"cypress".to_string())
1642        );
1643        assert!(
1644            result
1645                .referenced_dependencies
1646                .contains(&"@cypress/code-coverage".to_string())
1647        );
1648    }
1649
1650    #[test]
1651    fn builtin_plugin_count_is_expected() {
1652        let plugins = registry::builtin::create_builtin_plugins();
1653        assert!(
1654            plugins.len() >= 80,
1655            "expected at least 80 built-in plugins, got {}",
1656            plugins.len()
1657        );
1658    }
1659}