fallow_core/plugins/
tooling.rs1use rustc_hash::FxHashSet;
13
14const CATALOGUE_TOML: &str = include_str!("../../data/tooling.toml");
17
18#[cfg(test)]
26const FRAMEWORK_PLUGIN_FAMILY_PREFIXES: &[&str] = &[
27 "vite-plugin-",
28 "prettier-plugin-",
29 "eslint-plugin-",
30 "rollup-plugin-",
31];
32
33#[cfg(test)]
38const FRAMEWORK_PLUGIN_SCOPED_PREFIXES: &[&str] = &["@rollup/plugin-"];
39
40#[derive(serde::Deserialize)]
41struct ToolingCatalogue {
42 #[serde(default)]
43 prefix: Vec<PrefixEntry>,
44 #[serde(default)]
45 exact: Vec<ExactEntry>,
46}
47
48#[derive(serde::Deserialize)]
49struct PrefixEntry {
50 pattern: String,
54 #[expect(
56 dead_code,
57 reason = "documentation field, surfaced via the catalogue source"
58 )]
59 #[serde(default)]
60 notes: Option<String>,
61}
62
63#[derive(serde::Deserialize)]
64struct ExactEntry {
65 name: String,
67 #[expect(
69 dead_code,
70 reason = "documentation field, surfaced via the catalogue source"
71 )]
72 #[serde(default)]
73 ecosystem: Option<String>,
74}
75
76struct Catalogue {
78 prefixes: Vec<String>,
79 exact: FxHashSet<String>,
80}
81
82fn catalogue() -> &'static Catalogue {
86 static CATALOGUE: std::sync::OnceLock<Catalogue> = std::sync::OnceLock::new();
87 CATALOGUE.get_or_init(|| {
88 let parsed: ToolingCatalogue = toml::from_str(CATALOGUE_TOML).expect(
89 "embedded crates/core/data/tooling.toml must parse; run \
90 `cargo test -p fallow-core catalogue_parses` to see the error",
91 );
92 Catalogue {
93 prefixes: parsed.prefix.into_iter().map(|p| p.pattern).collect(),
94 exact: parsed.exact.into_iter().map(|e| e.name).collect(),
95 }
96 })
97}
98
99#[must_use]
105pub fn is_known_tooling_dependency(name: &str) -> bool {
106 let catalogue = catalogue();
107 catalogue
108 .prefixes
109 .iter()
110 .any(|p| name.starts_with(p.as_str()))
111 || catalogue.exact.contains(name)
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
121 fn types_prefix_matches_scoped() {
122 assert!(is_known_tooling_dependency("@types/node"));
123 assert!(is_known_tooling_dependency("@types/react"));
124 assert!(is_known_tooling_dependency("@types/express"));
125 }
126
127 #[test]
128 fn types_prefix_does_not_match_similar_names() {
129 assert!(!is_known_tooling_dependency("type-fest"));
131 assert!(!is_known_tooling_dependency("typesafe-actions"));
132 }
133
134 #[test]
135 fn storybook_not_blanket_matched() {
136 assert!(!is_known_tooling_dependency("@storybook/react"));
138 assert!(!is_known_tooling_dependency("@storybook/addon-essentials"));
139 assert!(!is_known_tooling_dependency("storybook"));
140 }
141
142 #[test]
143 fn testing_library_prefix_matches() {
144 assert!(is_known_tooling_dependency("@testing-library/react"));
145 assert!(is_known_tooling_dependency("@testing-library/jest-dom"));
146 }
147
148 #[test]
149 fn babel_not_blanket_matched() {
150 assert!(!is_known_tooling_dependency("@babel/core"));
152 assert!(!is_known_tooling_dependency("@babel/preset-env"));
153 assert!(!is_known_tooling_dependency("babel-loader"));
154 assert!(!is_known_tooling_dependency("babel-jest"));
155 }
156
157 #[test]
158 fn vitest_prefix_matches() {
159 assert!(is_known_tooling_dependency("@vitest/coverage-v8"));
160 assert!(is_known_tooling_dependency("@vitest/ui"));
161 }
162
163 #[test]
164 fn eslint_not_blanket_matched() {
165 assert!(!is_known_tooling_dependency("eslint"));
167 assert!(!is_known_tooling_dependency("eslint-plugin-react"));
168 assert!(!is_known_tooling_dependency("eslint-config-next"));
169 assert!(!is_known_tooling_dependency("@typescript-eslint/parser"));
170 }
171
172 #[test]
173 fn biomejs_prefix_matches() {
174 assert!(is_known_tooling_dependency("@biomejs/biome"));
175 }
176
177 #[test]
180 fn exact_typescript_matches() {
181 assert!(is_known_tooling_dependency("typescript"));
182 }
183
184 #[test]
185 fn exact_prettier_matches() {
186 assert!(is_known_tooling_dependency("prettier"));
187 }
188
189 #[test]
190 fn exact_vitest_matches() {
191 assert!(is_known_tooling_dependency("vitest"));
192 }
193
194 #[test]
195 fn exact_jest_matches() {
196 assert!(is_known_tooling_dependency("jest"));
197 }
198
199 #[test]
200 fn exact_vite_matches() {
201 assert!(is_known_tooling_dependency("vite"));
202 }
203
204 #[test]
205 fn exact_esbuild_matches() {
206 assert!(is_known_tooling_dependency("esbuild"));
207 }
208
209 #[test]
210 fn exact_tsup_matches() {
211 assert!(is_known_tooling_dependency("tsup"));
212 }
213
214 #[test]
215 fn exact_turbo_matches() {
216 assert!(is_known_tooling_dependency("turbo"));
217 }
218
219 #[test]
222 fn common_runtime_deps_not_tooling() {
223 assert!(!is_known_tooling_dependency("react"));
224 assert!(!is_known_tooling_dependency("react-dom"));
225 assert!(!is_known_tooling_dependency("express"));
226 assert!(!is_known_tooling_dependency("lodash"));
227 assert!(!is_known_tooling_dependency("next"));
228 assert!(!is_known_tooling_dependency("vue"));
229 assert!(!is_known_tooling_dependency("axios"));
230 }
231
232 #[test]
233 fn empty_string_not_tooling() {
234 assert!(!is_known_tooling_dependency(""));
235 }
236
237 #[test]
238 fn near_miss_not_tooling() {
239 assert!(!is_known_tooling_dependency("type-fest"));
241 assert!(!is_known_tooling_dependency("typestyle"));
242 assert!(!is_known_tooling_dependency("prettier-bytes")); }
246
247 #[test]
248 fn sass_variants_are_tooling() {
249 assert!(is_known_tooling_dependency("sass"));
250 assert!(is_known_tooling_dependency("sass-embedded"));
251 }
252
253 #[test]
254 fn framework_plugin_packages_no_longer_exact_matched() {
255 assert!(!is_known_tooling_dependency("vite-plugin-svgr"));
261 assert!(!is_known_tooling_dependency("vite-plugin-eslint"));
262 assert!(!is_known_tooling_dependency("prettier-plugin-tailwindcss"));
263 assert!(!is_known_tooling_dependency(
264 "prettier-plugin-organize-imports"
265 ));
266 assert!(!is_known_tooling_dependency(
267 "@ianvs/prettier-plugin-sort-imports"
268 ));
269 }
270
271 #[test]
274 fn electron_forge_prefix_matches() {
275 assert!(is_known_tooling_dependency("@electron-forge/cli"));
276 assert!(is_known_tooling_dependency(
277 "@electron-forge/maker-squirrel"
278 ));
279 }
280
281 #[test]
282 fn electron_prefix_matches() {
283 assert!(is_known_tooling_dependency("@electron/rebuild"));
284 assert!(is_known_tooling_dependency("@electron/notarize"));
285 }
286
287 #[test]
288 fn formatjs_prefix_matches() {
289 assert!(is_known_tooling_dependency("@formatjs/cli"));
290 assert!(is_known_tooling_dependency("@formatjs/intl"));
291 }
292
293 #[test]
294 fn rollup_not_blanket_matched() {
295 assert!(!is_known_tooling_dependency("@rollup/plugin-commonjs"));
297 assert!(!is_known_tooling_dependency("@rollup/plugin-node-resolve"));
298 assert!(!is_known_tooling_dependency("@rollup/plugin-typescript"));
299 }
300
301 #[test]
302 fn semantic_release_prefix_matches() {
303 assert!(is_known_tooling_dependency("@semantic-release/github"));
304 assert!(is_known_tooling_dependency("@semantic-release/npm"));
305 assert!(is_known_tooling_dependency("semantic-release"));
306 }
307
308 #[test]
309 fn release_it_prefix_matches() {
310 assert!(is_known_tooling_dependency(
311 "@release-it/conventional-changelog"
312 ));
313 }
314
315 #[test]
316 fn lerna_lite_prefix_matches() {
317 assert!(is_known_tooling_dependency("@lerna-lite/cli"));
318 assert!(is_known_tooling_dependency("@lerna-lite/publish"));
319 }
320
321 #[test]
322 fn changesets_prefix_matches() {
323 assert!(is_known_tooling_dependency("@changesets/cli"));
324 assert!(is_known_tooling_dependency("@changesets/changelog-github"));
325 }
326
327 #[test]
328 fn graphql_codegen_prefix_matches() {
329 assert!(is_known_tooling_dependency("@graphql-codegen/cli"));
330 assert!(is_known_tooling_dependency(
331 "@graphql-codegen/typescript-operations"
332 ));
333 }
334
335 #[test]
336 fn secretlint_prefix_matches() {
337 assert!(is_known_tooling_dependency("secretlint"));
338 assert!(is_known_tooling_dependency(
339 "@secretlint/secretlint-rule-preset-recommend"
340 ));
341 }
342
343 #[test]
344 fn oxlint_prefix_matches() {
345 assert!(is_known_tooling_dependency("oxlint"));
346 }
347
348 #[test]
349 fn react_native_community_prefix_matches() {
350 assert!(is_known_tooling_dependency("@react-native-community/cli"));
351 assert!(is_known_tooling_dependency(
352 "@react-native-community/cli-platform-android"
353 ));
354 }
355
356 #[test]
357 fn react_native_prefix_matches() {
358 assert!(is_known_tooling_dependency("@react-native/metro-config"));
359 assert!(is_known_tooling_dependency(
360 "@react-native/typescript-config"
361 ));
362 }
363
364 #[test]
365 fn jest_prefix_matches() {
366 assert!(is_known_tooling_dependency("@jest/globals"));
367 assert!(is_known_tooling_dependency("@jest/types"));
368 }
369
370 #[test]
371 fn playwright_prefix_matches() {
372 assert!(is_known_tooling_dependency("@playwright/test"));
373 assert!(is_known_tooling_dependency("playwright"));
374 }
375
376 #[test]
377 fn tapjs_prefix_matches() {
378 assert!(is_known_tooling_dependency("@tapjs/test"));
379 assert!(is_known_tooling_dependency("@tapjs/snapshot"));
380 }
381
382 #[test]
385 fn exact_tap_matches() {
386 assert!(is_known_tooling_dependency("tap"));
387 }
388
389 #[test]
390 fn exact_rolldown_matches() {
391 assert!(is_known_tooling_dependency("rolldown"));
392 assert!(is_known_tooling_dependency("rolldown-vite"));
393 }
394
395 #[test]
396 fn exact_electron_matches() {
397 assert!(is_known_tooling_dependency("electron"));
398 assert!(is_known_tooling_dependency("electron-builder"));
399 assert!(is_known_tooling_dependency("electron-vite"));
400 }
401
402 #[test]
403 fn exact_sharp_matches() {
404 assert!(is_known_tooling_dependency("sharp"));
405 }
406
407 #[test]
408 fn exact_puppeteer_matches() {
409 assert!(is_known_tooling_dependency("puppeteer"));
410 }
411
412 #[test]
413 fn exact_madge_matches() {
414 assert!(is_known_tooling_dependency("madge"));
415 }
416
417 #[test]
418 fn exact_patch_package_matches() {
419 assert!(is_known_tooling_dependency("patch-package"));
420 }
421
422 #[test]
423 fn exact_nx_matches() {
424 assert!(is_known_tooling_dependency("nx"));
425 }
426
427 #[test]
428 fn exact_vue_tsc_matches() {
429 assert!(is_known_tooling_dependency("vue-tsc"));
430 }
431
432 #[test]
433 fn exact_tsconfig_packages_match() {
434 assert!(is_known_tooling_dependency("@tsconfig/node20"));
435 assert!(is_known_tooling_dependency("@tsconfig/react-native"));
436 assert!(is_known_tooling_dependency("@vue/tsconfig"));
437 }
438
439 #[test]
440 fn exact_vitejs_plugins_match() {
441 assert!(is_known_tooling_dependency("@vitejs/plugin-vue"));
442 assert!(is_known_tooling_dependency("@vitejs/plugin-react"));
443 assert!(is_known_tooling_dependency("@vitejs/plugin-react-swc"));
444 assert!(is_known_tooling_dependency("@vitejs/plugin-legacy"));
445 }
446
447 #[test]
448 fn exact_oxc_transform_matches() {
449 assert!(is_known_tooling_dependency("oxc-transform"));
450 }
451
452 #[test]
453 fn exact_typescript_native_preview_matches() {
454 assert!(is_known_tooling_dependency("@typescript/native-preview"));
455 }
456
457 #[test]
458 fn exact_tw_animate_css_matches() {
459 assert!(is_known_tooling_dependency("tw-animate-css"));
460 }
461
462 #[test]
463 fn exact_manypkg_cli_matches() {
464 assert!(is_known_tooling_dependency("@manypkg/cli"));
465 }
466
467 #[test]
468 fn exact_swc_variants_match() {
469 assert!(is_known_tooling_dependency("@swc/core"));
470 assert!(is_known_tooling_dependency("@swc/jest"));
471 }
472
473 #[test]
476 fn runtime_deps_with_similar_names_not_tooling() {
477 assert!(!is_known_tooling_dependency("react-scripts"));
479 assert!(!is_known_tooling_dependency("express-validator"));
480 assert!(!is_known_tooling_dependency("sass-loader")); }
482
483 #[test]
484 fn postcss_not_blanket_matched() {
485 assert!(!is_known_tooling_dependency("postcss-modules"));
488 assert!(!is_known_tooling_dependency("postcss-import"));
489 assert!(!is_known_tooling_dependency("autoprefixer"));
490 assert!(!is_known_tooling_dependency("tailwindcss"));
491 assert!(!is_known_tooling_dependency("@tailwindcss/typography"));
492 }
493
494 #[test]
495 fn catalogue_is_deterministic() {
496 assert_eq!(
498 is_known_tooling_dependency("typescript"),
499 is_known_tooling_dependency("typescript")
500 );
501 assert!(is_known_tooling_dependency("typescript"));
502 }
503
504 #[test]
507 fn catalogue_parses() {
508 let cat = catalogue();
512 assert!(!cat.prefixes.is_empty(), "catalogue must have prefixes");
513 assert!(!cat.exact.is_empty(), "catalogue must have exact entries");
514 assert!(cat.exact.contains("typescript"));
515 assert!(cat.prefixes.iter().any(|p| p == "@types/"));
516 }
517
518 #[test]
519 fn catalogue_has_no_empty_or_whitespace_prefixes() {
520 for prefix in &catalogue().prefixes {
523 assert!(
524 !prefix.trim().is_empty(),
525 "catalogue prefix must be non-empty / non-whitespace; got {prefix:?}"
526 );
527 }
528 }
529
530 #[test]
531 fn catalogue_has_no_duplicate_entries() {
532 let parsed: ToolingCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
535
536 let mut seen_exact = FxHashSet::default();
537 for entry in &parsed.exact {
538 assert!(
539 seen_exact.insert(entry.name.as_str()),
540 "duplicate exact catalogue entry: {:?}",
541 entry.name
542 );
543 }
544
545 let mut seen_prefix = FxHashSet::default();
546 for entry in &parsed.prefix {
547 assert!(
548 seen_prefix.insert(entry.pattern.as_str()),
549 "duplicate prefix catalogue entry: {:?}",
550 entry.pattern
551 );
552 }
553 }
554
555 #[test]
556 fn catalogue_rejects_framework_plugin_exact_entries() {
557 let parsed: ToolingCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
563 for entry in &parsed.exact {
564 let tail = entry
568 .name
569 .strip_prefix('@')
570 .and_then(|rest| rest.split_once('/'))
571 .map(|(_scope, tail)| tail);
572 for bad in FRAMEWORK_PLUGIN_FAMILY_PREFIXES {
573 assert!(
574 !entry.name.starts_with(bad) && !tail.is_some_and(|t| t.starts_with(bad)),
575 "exact catalogue entry {:?} is a framework plugin ({bad}); \
576 credit it in the relevant plugin's config parser instead of the catalogue",
577 entry.name,
578 );
579 }
580 for bad in FRAMEWORK_PLUGIN_SCOPED_PREFIXES {
581 assert!(
582 !entry.name.starts_with(bad),
583 "exact catalogue entry {:?} is a framework plugin ({bad}); \
584 credit it in the relevant plugin's config parser instead of the catalogue",
585 entry.name,
586 );
587 }
588 }
589 }
590}