1use std::path::{Path, PathBuf};
13
14use fallow_config::{EntryPointRole, PackageJson, UsedClassMemberRule};
15use regex::Regex;
16
17const TEST_ENTRY_POINT_PLUGINS: &[&str] = &[
18 "ava",
19 "cucumber",
20 "cypress",
21 "jest",
22 "mocha",
23 "playwright",
24 "tap",
25 "tsd",
26 "vitest",
27 "webdriverio",
28];
29
30const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
31 "adonis",
32 "angular",
33 "astro",
34 "convex",
35 "docusaurus",
36 "electron",
37 "ember",
38 "expo",
39 "expo-router",
40 "gatsby",
41 "hardhat",
42 "nestjs",
43 "next-intl",
44 "nextjs",
45 "nitro",
46 "nuxt",
47 "parcel",
48 "qwik",
49 "react-native",
50 "react-router",
51 "redwoodsdk",
52 "remix",
53 "rolldown",
54 "rollup",
55 "rsbuild",
56 "rspack",
57 "sanity",
58 "sveltekit",
59 "tanstack-router",
60 "tsdown",
61 "tsup",
62 "vite",
63 "vitepress",
64 "webpack",
65 "wrangler",
66];
67
68#[cfg(test)]
69const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
70 "content-collections",
71 "danger",
72 "drizzle",
73 "fumadocs",
74 "i18next",
75 "knex",
76 "kysely",
77 "msw",
78 "opencode",
79 "prisma",
80 "storybook",
81 "stryker",
82 "typeorm",
83];
84
85#[derive(Debug, Default)]
87pub struct PluginResult {
88 pub entry_patterns: Vec<PathRule>,
90 pub replace_entry_patterns: bool,
95 pub replace_used_export_rules: bool,
98 pub used_exports: Vec<UsedExportRule>,
100 pub used_class_members: Vec<UsedClassMemberRule>,
105 pub referenced_dependencies: Vec<String>,
107 pub always_used_files: Vec<String>,
109 pub path_aliases: Vec<(String, String)>,
111 pub setup_files: Vec<PathBuf>,
113 pub fixture_patterns: Vec<String>,
115 pub scss_include_paths: Vec<PathBuf>,
123}
124
125impl PluginResult {
126 pub fn push_entry_pattern(&mut self, pattern: impl Into<String>) {
127 self.entry_patterns
128 .push(PathRule::new(normalize_entry_pattern(pattern.into())));
129 }
130
131 pub fn extend_entry_patterns<I, S>(&mut self, patterns: I)
132 where
133 I: IntoIterator<Item = S>,
134 S: Into<String>,
135 {
136 self.entry_patterns.extend(
137 patterns
138 .into_iter()
139 .map(|pat| PathRule::new(normalize_entry_pattern(pat.into()))),
140 );
141 }
142
143 pub fn push_used_export_rule(
144 &mut self,
145 pattern: impl Into<String>,
146 exports: impl IntoIterator<Item = impl Into<String>>,
147 ) {
148 self.used_exports
149 .push(UsedExportRule::new(pattern, exports));
150 }
151
152 #[must_use]
153 pub const fn is_empty(&self) -> bool {
154 self.entry_patterns.is_empty()
155 && self.used_exports.is_empty()
156 && self.used_class_members.is_empty()
157 && self.referenced_dependencies.is_empty()
158 && self.always_used_files.is_empty()
159 && self.path_aliases.is_empty()
160 && self.setup_files.is_empty()
161 && self.fixture_patterns.is_empty()
162 && self.scss_include_paths.is_empty()
163 }
164}
165
166fn normalize_entry_pattern(pattern: String) -> String {
173 pattern
174 .strip_prefix("./")
175 .map(str::to_owned)
176 .unwrap_or(pattern)
177}
178
179#[derive(Debug, Clone, Default, PartialEq, Eq)]
185pub struct PathRule {
186 pub pattern: String,
187 pub exclude_globs: Vec<String>,
188 pub exclude_regexes: Vec<String>,
189 pub exclude_segment_regexes: Vec<String>,
193}
194
195impl PathRule {
196 #[must_use]
197 pub fn new(pattern: impl Into<String>) -> Self {
198 Self {
199 pattern: pattern.into(),
200 exclude_globs: Vec::new(),
201 exclude_regexes: Vec::new(),
202 exclude_segment_regexes: Vec::new(),
203 }
204 }
205
206 #[must_use]
207 pub fn from_static(pattern: &'static str) -> Self {
208 Self::new(pattern)
209 }
210
211 #[must_use]
212 pub fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
213 where
214 I: IntoIterator<Item = S>,
215 S: Into<String>,
216 {
217 self.exclude_globs
218 .extend(patterns.into_iter().map(Into::into));
219 self
220 }
221
222 #[must_use]
223 pub fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
224 where
225 I: IntoIterator<Item = S>,
226 S: Into<String>,
227 {
228 self.exclude_regexes
229 .extend(patterns.into_iter().map(Into::into));
230 self
231 }
232
233 #[must_use]
234 pub fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
235 where
236 I: IntoIterator<Item = S>,
237 S: Into<String>,
238 {
239 self.exclude_segment_regexes
240 .extend(patterns.into_iter().map(Into::into));
241 self
242 }
243
244 #[must_use]
245 pub fn prefixed(&self, ws_prefix: &str) -> Self {
246 Self {
247 pattern: prefix_workspace_pattern(&self.pattern, ws_prefix),
248 exclude_globs: self
249 .exclude_globs
250 .iter()
251 .map(|pattern| prefix_workspace_pattern(pattern, ws_prefix))
252 .collect(),
253 exclude_regexes: self
254 .exclude_regexes
255 .iter()
256 .map(|pattern| prefix_workspace_regex(pattern, ws_prefix))
257 .collect(),
258 exclude_segment_regexes: self.exclude_segment_regexes.clone(),
259 }
260 }
261}
262
263#[derive(Debug, Clone, Default, PartialEq, Eq)]
265pub struct UsedExportRule {
266 pub path: PathRule,
267 pub exports: Vec<String>,
268}
269
270impl UsedExportRule {
271 #[must_use]
272 pub fn new(
273 pattern: impl Into<String>,
274 exports: impl IntoIterator<Item = impl Into<String>>,
275 ) -> Self {
276 Self {
277 path: PathRule::new(pattern),
278 exports: exports.into_iter().map(Into::into).collect(),
279 }
280 }
281
282 #[must_use]
283 pub fn from_static(pattern: &'static str, exports: &'static [&'static str]) -> Self {
284 Self::new(pattern, exports.iter().copied())
285 }
286
287 #[must_use]
288 pub fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
289 where
290 I: IntoIterator<Item = S>,
291 S: Into<String>,
292 {
293 self.path = self.path.with_excluded_globs(patterns);
294 self
295 }
296
297 #[must_use]
298 pub fn with_excluded_regexes<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_regexes(patterns);
304 self
305 }
306
307 #[must_use]
308 pub fn with_excluded_segment_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_segment_regexes(patterns);
314 self
315 }
316
317 #[must_use]
318 pub fn prefixed(&self, ws_prefix: &str) -> Self {
319 Self {
320 path: self.path.prefixed(ws_prefix),
321 exports: self.exports.clone(),
322 }
323 }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
328pub struct PluginUsedExportRule {
329 pub plugin_name: String,
330 pub rule: UsedExportRule,
331}
332
333impl PluginUsedExportRule {
334 #[must_use]
335 pub fn new(plugin_name: impl Into<String>, rule: UsedExportRule) -> Self {
336 Self {
337 plugin_name: plugin_name.into(),
338 rule,
339 }
340 }
341
342 #[must_use]
343 pub fn prefixed(&self, ws_prefix: &str) -> Self {
344 Self {
345 plugin_name: self.plugin_name.clone(),
346 rule: self.rule.prefixed(ws_prefix),
347 }
348 }
349}
350
351#[derive(Debug, Clone)]
353pub(crate) struct CompiledPathRule {
354 include: globset::GlobMatcher,
355 exclude_globs: Vec<globset::GlobMatcher>,
356 exclude_regexes: Vec<Regex>,
357 exclude_segment_regexes: Vec<Regex>,
358}
359
360impl CompiledPathRule {
361 pub(crate) fn for_entry_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
362 let include = match globset::GlobBuilder::new(&rule.pattern)
363 .literal_separator(true)
364 .build()
365 {
366 Ok(glob) => glob.compile_matcher(),
367 Err(err) => {
368 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
369 return None;
370 }
371 };
372 Some(Self {
373 include,
374 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
375 exclude_regexes: compile_excluded_regexes(
376 &rule.exclude_regexes,
377 rule_kind,
378 &rule.pattern,
379 ),
380 exclude_segment_regexes: compile_excluded_segment_regexes(
381 &rule.exclude_segment_regexes,
382 rule_kind,
383 &rule.pattern,
384 ),
385 })
386 }
387
388 pub(crate) fn for_used_export_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
389 let include = match globset::Glob::new(&rule.pattern) {
390 Ok(glob) => glob.compile_matcher(),
391 Err(err) => {
392 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
393 return None;
394 }
395 };
396 Some(Self {
397 include,
398 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
399 exclude_regexes: compile_excluded_regexes(
400 &rule.exclude_regexes,
401 rule_kind,
402 &rule.pattern,
403 ),
404 exclude_segment_regexes: compile_excluded_segment_regexes(
405 &rule.exclude_segment_regexes,
406 rule_kind,
407 &rule.pattern,
408 ),
409 })
410 }
411
412 #[must_use]
413 pub(crate) fn matches(&self, path: &str) -> bool {
414 self.include.is_match(path)
415 && !self.exclude_globs.iter().any(|glob| glob.is_match(path))
416 && !self
417 .exclude_regexes
418 .iter()
419 .any(|regex| regex.is_match(path))
420 && !matches_segment_regex(path, &self.exclude_segment_regexes)
421 }
422}
423
424fn prefix_workspace_pattern(pattern: &str, ws_prefix: &str) -> String {
425 if pattern.starts_with(ws_prefix) || pattern.starts_with('/') {
426 pattern.to_string()
427 } else {
428 format!("{ws_prefix}/{pattern}")
429 }
430}
431
432fn prefix_workspace_regex(pattern: &str, ws_prefix: &str) -> String {
433 if let Some(pattern) = pattern.strip_prefix('^') {
434 format!("^{}/{}", regex::escape(ws_prefix), pattern)
435 } else {
436 format!("^{}/(?:{})", regex::escape(ws_prefix), pattern)
437 }
438}
439
440fn compile_excluded_globs(
441 patterns: &[String],
442 rule_kind: &str,
443 rule_pattern: &str,
444) -> Vec<globset::GlobMatcher> {
445 patterns
446 .iter()
447 .filter_map(|pattern| {
448 match globset::GlobBuilder::new(pattern)
449 .literal_separator(true)
450 .build()
451 {
452 Ok(glob) => Some(glob.compile_matcher()),
453 Err(err) => {
454 tracing::warn!(
455 "skipping invalid excluded glob '{}' for {} '{}': {err}",
456 pattern,
457 rule_kind,
458 rule_pattern
459 );
460 None
461 }
462 }
463 })
464 .collect()
465}
466
467fn compile_excluded_regexes(
468 patterns: &[String],
469 rule_kind: &str,
470 rule_pattern: &str,
471) -> Vec<Regex> {
472 patterns
473 .iter()
474 .filter_map(|pattern| match Regex::new(pattern) {
475 Ok(regex) => Some(regex),
476 Err(err) => {
477 tracing::warn!(
478 "skipping invalid excluded regex '{}' for {} '{}': {err}",
479 pattern,
480 rule_kind,
481 rule_pattern
482 );
483 None
484 }
485 })
486 .collect()
487}
488
489fn compile_excluded_segment_regexes(
490 patterns: &[String],
491 rule_kind: &str,
492 rule_pattern: &str,
493) -> Vec<Regex> {
494 patterns
495 .iter()
496 .filter_map(|pattern| match Regex::new(pattern) {
497 Ok(regex) => Some(regex),
498 Err(err) => {
499 tracing::warn!(
500 "skipping invalid excluded segment regex '{}' for {} '{}': {err}",
501 pattern,
502 rule_kind,
503 rule_pattern
504 );
505 None
506 }
507 })
508 .collect()
509}
510
511fn matches_segment_regex(path: &str, regexes: &[Regex]) -> bool {
512 path.split('/')
513 .any(|segment| regexes.iter().any(|regex| regex.is_match(segment)))
514}
515
516impl From<String> for PathRule {
517 fn from(pattern: String) -> Self {
518 Self::new(pattern)
519 }
520}
521
522impl From<&str> for PathRule {
523 fn from(pattern: &str) -> Self {
524 Self::new(pattern)
525 }
526}
527
528impl std::ops::Deref for PathRule {
529 type Target = str;
530
531 fn deref(&self) -> &Self::Target {
532 &self.pattern
533 }
534}
535
536impl PartialEq<&str> for PathRule {
537 fn eq(&self, other: &&str) -> bool {
538 self.pattern == *other
539 }
540}
541
542impl PartialEq<str> for PathRule {
543 fn eq(&self, other: &str) -> bool {
544 self.pattern == other
545 }
546}
547
548impl PartialEq<String> for PathRule {
549 fn eq(&self, other: &String) -> bool {
550 &self.pattern == other
551 }
552}
553
554pub trait Plugin: Send + Sync {
556 fn name(&self) -> &'static str;
558
559 fn enablers(&self) -> &'static [&'static str] {
562 &[]
563 }
564
565 fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
568 let deps = pkg.all_dependency_names();
569 self.is_enabled_with_deps(&deps, root)
570 }
571
572 fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
575 let enablers = self.enablers();
576 if enablers.is_empty() {
577 return false;
578 }
579 enablers.iter().any(|enabler| {
580 if enabler.ends_with('/') {
581 deps.iter().any(|d| d.starts_with(enabler))
583 } else {
584 deps.iter().any(|d| d == enabler)
585 }
586 })
587 }
588
589 fn entry_patterns(&self) -> &'static [&'static str] {
591 &[]
592 }
593
594 fn entry_pattern_rules(&self) -> Vec<PathRule> {
596 self.entry_patterns()
597 .iter()
598 .map(|pattern| PathRule::from_static(pattern))
599 .collect()
600 }
601
602 fn entry_point_role(&self) -> EntryPointRole {
607 builtin_entry_point_role(self.name())
608 }
609
610 fn config_patterns(&self) -> &'static [&'static str] {
612 &[]
613 }
614
615 fn always_used(&self) -> &'static [&'static str] {
617 &[]
618 }
619
620 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
622 vec![]
623 }
624
625 fn used_export_rules(&self) -> Vec<UsedExportRule> {
627 self.used_exports()
628 .into_iter()
629 .map(|(pattern, exports)| UsedExportRule::from_static(pattern, exports))
630 .collect()
631 }
632
633 fn used_class_members(&self) -> &'static [&'static str] {
638 &[]
639 }
640
641 fn used_class_member_rules(&self) -> Vec<UsedClassMemberRule> {
649 Vec::new()
650 }
651
652 fn fixture_glob_patterns(&self) -> &'static [&'static str] {
657 &[]
658 }
659
660 fn discovery_hidden_dirs(&self) -> &'static [&'static str] {
665 &[]
666 }
667
668 fn tooling_dependencies(&self) -> &'static [&'static str] {
671 &[]
672 }
673
674 fn virtual_module_prefixes(&self) -> &'static [&'static str] {
679 &[]
680 }
681
682 fn virtual_package_suffixes(&self) -> &'static [&'static str] {
688 &[]
689 }
690
691 fn generated_import_patterns(&self) -> &'static [&'static str] {
697 &[]
698 }
699
700 fn generated_type_import_prefixes(&self) -> &'static [&'static str] {
705 &[]
706 }
707
708 fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
718 vec![]
719 }
720
721 fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
726 PluginResult::default()
727 }
728
729 fn package_json_config_key(&self) -> Option<&'static str> {
734 None
735 }
736}
737
738fn builtin_entry_point_role(name: &str) -> EntryPointRole {
739 if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
740 EntryPointRole::Test
741 } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
742 EntryPointRole::Runtime
743 } else {
744 EntryPointRole::Support
745 }
746}
747
748macro_rules! define_plugin {
809 (
812 struct $name:ident => $display:expr,
813 enablers: $enablers:expr
814 $(, entry_patterns: $entry:expr)?
815 $(, config_patterns: $config:expr)?
816 $(, always_used: $always:expr)?
817 $(, tooling_dependencies: $tooling:expr)?
818 $(, fixture_glob_patterns: $fixtures:expr)?
819 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
820 $(, virtual_module_prefixes: $virtual:expr)?
821 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
822 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
823 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
824 , resolve_config: imports_only
825 $(,)?
826 ) => {
827 pub struct $name;
828
829 impl Plugin for $name {
830 fn name(&self) -> &'static str {
831 $display
832 }
833
834 fn enablers(&self) -> &'static [&'static str] {
835 $enablers
836 }
837
838 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
839 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
840 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
841 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
842 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
843 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
844 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
845 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
846 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
847
848 $(
849 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
850 vec![$( ($pat, $exports) ),*]
851 }
852 )?
853
854 fn resolve_config(
855 &self,
856 config_path: &std::path::Path,
857 source: &str,
858 _root: &std::path::Path,
859 ) -> PluginResult {
860 let mut result = PluginResult::default();
861 let imports = crate::plugins::config_parser::extract_imports(source, config_path);
862 for imp in &imports {
863 let dep = crate::resolve::extract_package_name(imp);
864 result.referenced_dependencies.push(dep);
865 }
866 result
867 }
868 }
869 };
870
871 (
875 struct $name:ident => $display:expr,
876 enablers: $enablers:expr
877 $(, entry_patterns: $entry:expr)?
878 $(, config_patterns: $config:expr)?
879 $(, always_used: $always:expr)?
880 $(, tooling_dependencies: $tooling:expr)?
881 $(, fixture_glob_patterns: $fixtures:expr)?
882 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
883 $(, virtual_module_prefixes: $virtual:expr)?
884 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
885 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
886 $(, package_json_config_key: $pkg_key:expr)?
887 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
888 , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
889 $(,)?
890 ) => {
891 pub struct $name;
892
893 impl Plugin for $name {
894 fn name(&self) -> &'static str {
895 $display
896 }
897
898 fn enablers(&self) -> &'static [&'static str] {
899 $enablers
900 }
901
902 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
903 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
904 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
905 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
906 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
907 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
908 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
909 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
910 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
911
912 $(
913 fn package_json_config_key(&self) -> Option<&'static str> {
914 Some($pkg_key)
915 }
916 )?
917
918 $(
919 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
920 vec![$( ($pat, $exports) ),*]
921 }
922 )?
923
924 fn resolve_config(
925 &self,
926 $cp: &std::path::Path,
927 $src: &str,
928 $root: &std::path::Path,
929 ) -> PluginResult
930 $body
931 }
932 };
933
934 (
936 struct $name:ident => $display:expr,
937 enablers: $enablers:expr
938 $(, entry_patterns: $entry:expr)?
939 $(, config_patterns: $config:expr)?
940 $(, always_used: $always:expr)?
941 $(, tooling_dependencies: $tooling:expr)?
942 $(, fixture_glob_patterns: $fixtures:expr)?
943 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
944 $(, virtual_module_prefixes: $virtual:expr)?
945 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
946 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
947 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
948 $(,)?
949 ) => {
950 pub struct $name;
951
952 impl Plugin for $name {
953 fn name(&self) -> &'static str {
954 $display
955 }
956
957 fn enablers(&self) -> &'static [&'static str] {
958 $enablers
959 }
960
961 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
962 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
963 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
964 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
965 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
966 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
967 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
968 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
969 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
970
971 $(
972 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
973 vec![$( ($pat, $exports) ),*]
974 }
975 )?
976 }
977 };
978}
979
980pub mod config_parser;
981pub mod registry;
982mod tooling;
983
984pub use registry::{AggregatedPluginResult, PluginRegistry};
985pub use tooling::is_known_tooling_dependency;
986
987mod adonis;
988mod angular;
989mod astro;
990mod ava;
991mod babel;
992mod biome;
993mod bun;
994mod c8;
995mod capacitor;
996mod changesets;
997mod commitizen;
998mod commitlint;
999mod content_collections;
1000mod convex;
1001mod cspell;
1002mod cucumber;
1003mod cypress;
1004mod danger;
1005mod dependency_cruiser;
1006mod docusaurus;
1007mod drizzle;
1008mod electron;
1009mod ember;
1010mod eslint;
1011mod expo;
1012mod expo_router;
1013mod fumadocs;
1014mod gatsby;
1015mod graphql_codegen;
1016mod hardhat;
1017mod husky;
1018mod i18next;
1019mod jest;
1020mod karma;
1021mod knex;
1022mod kysely;
1023mod lefthook;
1024mod lexical;
1025mod lint_staged;
1026mod lit;
1027mod markdownlint;
1028mod mocha;
1029mod msw;
1030mod nestjs;
1031mod next_intl;
1032mod nextjs;
1033mod nitro;
1034mod nodemon;
1035mod nuxt;
1036mod nx;
1037mod nyc;
1038mod openapi_ts;
1039mod opencode;
1040mod oxlint;
1041mod pandacss;
1042mod parcel;
1043mod playwright;
1044mod plop;
1045mod pm2;
1046mod pnpm;
1047mod postcss;
1048mod prettier;
1049mod prisma;
1050mod qwik;
1051mod react_native;
1052mod react_router;
1053mod redwoodsdk;
1054mod relay;
1055mod remark;
1056mod remix;
1057mod rolldown;
1058mod rollup;
1059mod rsbuild;
1060mod rspack;
1061mod sanity;
1062mod semantic_release;
1063mod sentry;
1064mod simple_git_hooks;
1065mod storybook;
1066mod stryker;
1067mod stylelint;
1068mod sveltekit;
1069mod svgo;
1070mod svgr;
1071mod swc;
1072mod syncpack;
1073mod tailwind;
1074mod tanstack_router;
1075mod tap;
1076mod tsd;
1077mod tsdown;
1078mod tsup;
1079mod turborepo;
1080mod typedoc;
1081mod typeorm;
1082mod typescript;
1083mod unocss;
1084mod vite;
1085mod vitepress;
1086mod vitest;
1087mod webdriverio;
1088mod webpack;
1089mod wrangler;
1090mod wuchale;
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095 use std::path::Path;
1096
1097 #[test]
1100 fn is_enabled_with_deps_exact_match() {
1101 let plugin = nextjs::NextJsPlugin;
1102 let deps = vec!["next".to_string()];
1103 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1104 }
1105
1106 #[test]
1107 fn is_enabled_with_deps_no_match() {
1108 let plugin = nextjs::NextJsPlugin;
1109 let deps = vec!["react".to_string()];
1110 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1111 }
1112
1113 #[test]
1114 fn is_enabled_with_deps_empty_deps() {
1115 let plugin = nextjs::NextJsPlugin;
1116 let deps: Vec<String> = vec![];
1117 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1118 }
1119
1120 #[test]
1121 fn entry_point_role_defaults_are_centralized() {
1122 assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
1123 assert_eq!(
1124 vitest::VitestPlugin.entry_point_role(),
1125 EntryPointRole::Test
1126 );
1127 assert_eq!(
1128 storybook::StorybookPlugin.entry_point_role(),
1129 EntryPointRole::Support
1130 );
1131 assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1132 }
1133
1134 #[test]
1135 fn plugins_with_entry_patterns_have_explicit_role_intent() {
1136 let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1137 TEST_ENTRY_POINT_PLUGINS
1138 .iter()
1139 .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1140 .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1141 .copied()
1142 .collect();
1143
1144 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1145 if plugin.entry_patterns().is_empty() {
1146 continue;
1147 }
1148 assert!(
1149 runtime_or_test_or_support.contains(plugin.name()),
1150 "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1151 plugin.name()
1152 );
1153 }
1154 }
1155
1156 #[test]
1159 fn plugin_result_is_empty_when_default() {
1160 let r = PluginResult::default();
1161 assert!(r.is_empty());
1162 }
1163
1164 #[test]
1165 fn plugin_result_not_empty_with_entry_patterns() {
1166 let r = PluginResult {
1167 entry_patterns: vec!["*.ts".into()],
1168 ..Default::default()
1169 };
1170 assert!(!r.is_empty());
1171 }
1172
1173 #[test]
1174 fn plugin_result_not_empty_with_referenced_deps() {
1175 let r = PluginResult {
1176 referenced_dependencies: vec!["lodash".to_string()],
1177 ..Default::default()
1178 };
1179 assert!(!r.is_empty());
1180 }
1181
1182 #[test]
1183 fn plugin_result_not_empty_with_setup_files() {
1184 let r = PluginResult {
1185 setup_files: vec![PathBuf::from("/setup.ts")],
1186 ..Default::default()
1187 };
1188 assert!(!r.is_empty());
1189 }
1190
1191 #[test]
1192 fn plugin_result_not_empty_with_always_used_files() {
1193 let r = PluginResult {
1194 always_used_files: vec!["**/*.stories.tsx".to_string()],
1195 ..Default::default()
1196 };
1197 assert!(!r.is_empty());
1198 }
1199
1200 #[test]
1201 fn plugin_result_not_empty_with_fixture_patterns() {
1202 let r = PluginResult {
1203 fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
1204 ..Default::default()
1205 };
1206 assert!(!r.is_empty());
1207 }
1208
1209 #[test]
1212 fn is_enabled_with_deps_prefix_match() {
1213 let plugin = storybook::StorybookPlugin;
1215 let deps = vec!["@storybook/react".to_string()];
1216 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1217 }
1218
1219 #[test]
1220 fn is_enabled_with_deps_prefix_no_match_without_slash() {
1221 let plugin = storybook::StorybookPlugin;
1223 let deps = vec!["@storybookish".to_string()];
1224 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1225 }
1226
1227 #[test]
1228 fn is_enabled_with_deps_multiple_enablers() {
1229 let plugin = vitest::VitestPlugin;
1231 let deps_vitest = vec!["vitest".to_string()];
1232 let deps_none = vec!["mocha".to_string()];
1233 assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
1234 assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
1235 }
1236
1237 #[test]
1240 fn plugin_default_methods_return_empty() {
1241 let plugin = commitizen::CommitizenPlugin;
1243 assert!(
1244 plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
1245 );
1246 assert!(plugin.virtual_module_prefixes().is_empty());
1247 assert!(plugin.virtual_package_suffixes().is_empty());
1248 assert!(plugin.path_aliases(Path::new("/project")).is_empty());
1249 assert!(
1250 plugin.package_json_config_key().is_none()
1251 || plugin.package_json_config_key().is_some()
1252 );
1253 }
1254
1255 #[test]
1256 fn plugin_resolve_config_default_returns_empty() {
1257 let plugin = commitizen::CommitizenPlugin;
1258 let result = plugin.resolve_config(
1259 Path::new("/project/config.js"),
1260 "const x = 1;",
1261 Path::new("/project"),
1262 );
1263 assert!(result.is_empty());
1264 }
1265
1266 #[test]
1269 fn is_enabled_with_deps_exact_and_prefix_both_work() {
1270 let plugin = storybook::StorybookPlugin;
1271 let deps_exact = vec!["storybook".to_string()];
1272 assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
1273 let deps_prefix = vec!["@storybook/vue3".to_string()];
1274 assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
1275 }
1276
1277 #[test]
1278 fn is_enabled_with_deps_multiple_enablers_remix() {
1279 let plugin = remix::RemixPlugin;
1280 let deps_node = vec!["@remix-run/node".to_string()];
1281 assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
1282 let deps_react = vec!["@remix-run/react".to_string()];
1283 assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
1284 let deps_cf = vec!["@remix-run/cloudflare".to_string()];
1285 assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
1286 }
1287
1288 struct MinimalPlugin;
1291 impl Plugin for MinimalPlugin {
1292 fn name(&self) -> &'static str {
1293 "minimal"
1294 }
1295 }
1296
1297 #[test]
1298 fn default_enablers_is_empty() {
1299 assert!(MinimalPlugin.enablers().is_empty());
1300 }
1301
1302 #[test]
1303 fn default_entry_patterns_is_empty() {
1304 assert!(MinimalPlugin.entry_patterns().is_empty());
1305 }
1306
1307 #[test]
1308 fn default_config_patterns_is_empty() {
1309 assert!(MinimalPlugin.config_patterns().is_empty());
1310 }
1311
1312 #[test]
1313 fn default_always_used_is_empty() {
1314 assert!(MinimalPlugin.always_used().is_empty());
1315 }
1316
1317 #[test]
1318 fn default_used_exports_is_empty() {
1319 assert!(MinimalPlugin.used_exports().is_empty());
1320 }
1321
1322 #[test]
1323 fn default_tooling_dependencies_is_empty() {
1324 assert!(MinimalPlugin.tooling_dependencies().is_empty());
1325 }
1326
1327 #[test]
1328 fn default_fixture_glob_patterns_is_empty() {
1329 assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
1330 }
1331
1332 #[test]
1333 fn default_virtual_module_prefixes_is_empty() {
1334 assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
1335 }
1336
1337 #[test]
1338 fn default_virtual_package_suffixes_is_empty() {
1339 assert!(MinimalPlugin.virtual_package_suffixes().is_empty());
1340 }
1341
1342 #[test]
1343 fn default_path_aliases_is_empty() {
1344 assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
1345 }
1346
1347 #[test]
1348 fn default_resolve_config_returns_empty() {
1349 let r = MinimalPlugin.resolve_config(
1350 Path::new("config.js"),
1351 "export default {}",
1352 Path::new("/"),
1353 );
1354 assert!(r.is_empty());
1355 }
1356
1357 #[test]
1358 fn default_package_json_config_key_is_none() {
1359 assert!(MinimalPlugin.package_json_config_key().is_none());
1360 }
1361
1362 #[test]
1363 fn default_is_enabled_returns_false_when_no_enablers() {
1364 let deps = vec!["anything".to_string()];
1365 assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
1366 }
1367
1368 #[test]
1371 fn all_builtin_plugin_names_are_unique() {
1372 let plugins = registry::builtin::create_builtin_plugins();
1373 let mut seen = std::collections::BTreeSet::new();
1374 for p in &plugins {
1375 let name = p.name();
1376 assert!(seen.insert(name), "duplicate plugin name: {name}");
1377 }
1378 }
1379
1380 #[test]
1381 fn all_builtin_plugins_have_enablers() {
1382 let plugins = registry::builtin::create_builtin_plugins();
1383 for p in &plugins {
1384 assert!(
1385 !p.enablers().is_empty(),
1386 "plugin '{}' has no enablers",
1387 p.name()
1388 );
1389 }
1390 }
1391
1392 #[test]
1393 fn plugins_with_config_patterns_have_always_used() {
1394 let plugins = registry::builtin::create_builtin_plugins();
1395 for p in &plugins {
1396 if !p.config_patterns().is_empty() {
1397 assert!(
1398 !p.always_used().is_empty(),
1399 "plugin '{}' has config_patterns but no always_used",
1400 p.name()
1401 );
1402 }
1403 }
1404 }
1405
1406 #[test]
1409 fn framework_plugins_enablers() {
1410 let cases: Vec<(&dyn Plugin, &[&str])> = vec![
1411 (&nextjs::NextJsPlugin, &["next"]),
1412 (&nuxt::NuxtPlugin, &["nuxt"]),
1413 (&angular::AngularPlugin, &["@angular/core"]),
1414 (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
1415 (&gatsby::GatsbyPlugin, &["gatsby"]),
1416 ];
1417 for (plugin, expected_enablers) in cases {
1418 let enablers = plugin.enablers();
1419 for expected in expected_enablers {
1420 assert!(
1421 enablers.contains(expected),
1422 "plugin '{}' should have '{}'",
1423 plugin.name(),
1424 expected
1425 );
1426 }
1427 }
1428 }
1429
1430 #[test]
1431 fn testing_plugins_enablers() {
1432 let cases: Vec<(&dyn Plugin, &str)> = vec![
1433 (&jest::JestPlugin, "jest"),
1434 (&vitest::VitestPlugin, "vitest"),
1435 (&playwright::PlaywrightPlugin, "@playwright/test"),
1436 (&cypress::CypressPlugin, "cypress"),
1437 (&mocha::MochaPlugin, "mocha"),
1438 (&stryker::StrykerPlugin, "@stryker-mutator/core"),
1439 ];
1440 for (plugin, enabler) in cases {
1441 assert!(
1442 plugin.enablers().contains(&enabler),
1443 "plugin '{}' should have '{}'",
1444 plugin.name(),
1445 enabler
1446 );
1447 }
1448 }
1449
1450 #[test]
1451 fn bundler_plugins_enablers() {
1452 let cases: Vec<(&dyn Plugin, &str)> = vec![
1453 (&vite::VitePlugin, "vite"),
1454 (&webpack::WebpackPlugin, "webpack"),
1455 (&rollup::RollupPlugin, "rollup"),
1456 ];
1457 for (plugin, enabler) in cases {
1458 assert!(
1459 plugin.enablers().contains(&enabler),
1460 "plugin '{}' should have '{}'",
1461 plugin.name(),
1462 enabler
1463 );
1464 }
1465 }
1466
1467 #[test]
1468 fn test_plugins_have_test_entry_patterns() {
1469 let test_plugins: Vec<&dyn Plugin> = vec![
1470 &jest::JestPlugin,
1471 &vitest::VitestPlugin,
1472 &mocha::MochaPlugin,
1473 &tap::TapPlugin,
1474 &tsd::TsdPlugin,
1475 ];
1476 for plugin in test_plugins {
1477 let patterns = plugin.entry_patterns();
1478 assert!(
1479 !patterns.is_empty(),
1480 "test plugin '{}' should have entry patterns",
1481 plugin.name()
1482 );
1483 assert!(
1484 patterns
1485 .iter()
1486 .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
1487 "test plugin '{}' should have test/spec patterns",
1488 plugin.name()
1489 );
1490 }
1491 }
1492
1493 #[test]
1494 fn framework_plugins_have_entry_patterns() {
1495 let plugins: Vec<&dyn Plugin> = vec![
1496 &nextjs::NextJsPlugin,
1497 &nuxt::NuxtPlugin,
1498 &angular::AngularPlugin,
1499 &sveltekit::SvelteKitPlugin,
1500 ];
1501 for plugin in plugins {
1502 assert!(
1503 !plugin.entry_patterns().is_empty(),
1504 "framework plugin '{}' should have entry patterns",
1505 plugin.name()
1506 );
1507 }
1508 }
1509
1510 #[test]
1511 fn plugins_with_resolve_config_have_config_patterns() {
1512 let plugins: Vec<&dyn Plugin> = vec![
1513 &jest::JestPlugin,
1514 &vitest::VitestPlugin,
1515 &babel::BabelPlugin,
1516 &eslint::EslintPlugin,
1517 &webpack::WebpackPlugin,
1518 &storybook::StorybookPlugin,
1519 &typescript::TypeScriptPlugin,
1520 &postcss::PostCssPlugin,
1521 &nextjs::NextJsPlugin,
1522 &nuxt::NuxtPlugin,
1523 &angular::AngularPlugin,
1524 &nx::NxPlugin,
1525 &stryker::StrykerPlugin,
1526 &wuchale::WuchalePlugin,
1527 &rollup::RollupPlugin,
1528 &sveltekit::SvelteKitPlugin,
1529 &prettier::PrettierPlugin,
1530 ];
1531 for plugin in plugins {
1532 assert!(
1533 !plugin.config_patterns().is_empty(),
1534 "plugin '{}' with resolve_config should have config_patterns",
1535 plugin.name()
1536 );
1537 }
1538 }
1539
1540 #[test]
1541 fn plugin_tooling_deps_include_enabler_package() {
1542 let plugins: Vec<&dyn Plugin> = vec![
1543 &jest::JestPlugin,
1544 &vitest::VitestPlugin,
1545 &webpack::WebpackPlugin,
1546 &typescript::TypeScriptPlugin,
1547 &eslint::EslintPlugin,
1548 &prettier::PrettierPlugin,
1549 &danger::DangerPlugin,
1550 &stryker::StrykerPlugin,
1551 &wuchale::WuchalePlugin,
1552 ];
1553 for plugin in plugins {
1554 let tooling = plugin.tooling_dependencies();
1555 let enablers = plugin.enablers();
1556 assert!(
1557 enablers
1558 .iter()
1559 .any(|e| !e.ends_with('/') && tooling.contains(e)),
1560 "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
1561 plugin.name()
1562 );
1563 }
1564 }
1565
1566 #[test]
1567 fn nextjs_has_used_exports_for_pages() {
1568 let plugin = nextjs::NextJsPlugin;
1569 let exports = plugin.used_exports();
1570 assert!(!exports.is_empty());
1571 assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
1572 }
1573
1574 #[test]
1575 fn remix_has_used_exports_for_routes() {
1576 let plugin = remix::RemixPlugin;
1577 let exports = plugin.used_exports();
1578 assert!(!exports.is_empty());
1579 let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
1580 assert!(route_entry.is_some());
1581 let (_, names) = route_entry.unwrap();
1582 assert!(names.contains(&"loader"));
1583 assert!(names.contains(&"action"));
1584 assert!(names.contains(&"default"));
1585 }
1586
1587 #[test]
1588 fn sveltekit_has_used_exports_for_routes() {
1589 let plugin = sveltekit::SvelteKitPlugin;
1590 let exports = plugin.used_exports();
1591 assert!(!exports.is_empty());
1592 assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
1593 }
1594
1595 #[test]
1596 fn nuxt_has_hash_virtual_prefix() {
1597 assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
1598 }
1599
1600 #[test]
1601 fn sveltekit_has_dollar_virtual_prefixes() {
1602 let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
1603 assert!(prefixes.contains(&"$app/"));
1604 assert!(prefixes.contains(&"$env/"));
1605 assert!(prefixes.contains(&"$lib/"));
1606 }
1607
1608 #[test]
1609 fn sveltekit_has_lib_path_alias() {
1610 let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
1611 assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
1612 }
1613
1614 #[test]
1615 fn nuxt_has_tilde_path_alias() {
1616 let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
1617 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
1618 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
1619 }
1620
1621 #[test]
1622 fn jest_has_package_json_config_key() {
1623 assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
1624 }
1625
1626 #[test]
1627 fn tsd_has_package_json_config_key() {
1628 assert_eq!(tsd::TsdPlugin.package_json_config_key(), Some("tsd"));
1629 }
1630
1631 #[test]
1632 fn babel_has_package_json_config_key() {
1633 assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
1634 }
1635
1636 #[test]
1637 fn eslint_has_package_json_config_key() {
1638 assert_eq!(
1639 eslint::EslintPlugin.package_json_config_key(),
1640 Some("eslintConfig")
1641 );
1642 }
1643
1644 #[test]
1645 fn prettier_has_package_json_config_key() {
1646 assert_eq!(
1647 prettier::PrettierPlugin.package_json_config_key(),
1648 Some("prettier")
1649 );
1650 }
1651
1652 #[test]
1653 fn macro_generated_plugin_basic_properties() {
1654 let plugin = msw::MswPlugin;
1655 assert_eq!(plugin.name(), "msw");
1656 assert!(plugin.enablers().contains(&"msw"));
1657 assert!(!plugin.entry_patterns().is_empty());
1658 assert!(plugin.config_patterns().is_empty());
1659 assert!(!plugin.always_used().is_empty());
1660 assert!(!plugin.tooling_dependencies().is_empty());
1661 }
1662
1663 #[test]
1664 fn macro_generated_plugin_with_used_exports() {
1665 let plugin = remix::RemixPlugin;
1666 assert_eq!(plugin.name(), "remix");
1667 assert!(!plugin.used_exports().is_empty());
1668 }
1669
1670 #[test]
1671 fn macro_passes_through_virtual_package_suffixes() {
1672 define_plugin! {
1677 struct MacroSuffixSmokePlugin => "macro-suffix-smoke",
1678 enablers: &["macro-suffix-smoke"],
1679 virtual_package_suffixes: &["/__macro_smoke__"],
1680 }
1681
1682 let plugin = MacroSuffixSmokePlugin;
1683 assert_eq!(
1684 plugin.virtual_package_suffixes(),
1685 &["/__macro_smoke__"],
1686 "macro-declared virtual_package_suffixes must propagate to the trait method"
1687 );
1688 }
1689
1690 #[test]
1691 fn macro_generated_plugin_imports_only_resolve_config() {
1692 let plugin = cypress::CypressPlugin;
1693 let source = r"
1694 import { defineConfig } from 'cypress';
1695 import coveragePlugin from '@cypress/code-coverage';
1696 export default defineConfig({});
1697 ";
1698 let result = plugin.resolve_config(
1699 Path::new("cypress.config.ts"),
1700 source,
1701 Path::new("/project"),
1702 );
1703 assert!(
1704 result
1705 .referenced_dependencies
1706 .contains(&"cypress".to_string())
1707 );
1708 assert!(
1709 result
1710 .referenced_dependencies
1711 .contains(&"@cypress/code-coverage".to_string())
1712 );
1713 }
1714
1715 #[test]
1716 fn builtin_plugin_count_is_expected() {
1717 let plugins = registry::builtin::create_builtin_plugins();
1718 assert!(
1719 plugins.len() >= 80,
1720 "expected at least 80 built-in plugins, got {}",
1721 plugins.len()
1722 );
1723 }
1724}