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