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