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