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