Skip to main content

fallow_core/plugins/
tooling.rs

1//! General tooling dependency detection.
2//!
3//! Known dev dependencies that are tooling (used by CLI/config, not imported in
4//! application code). These complement the per-plugin `tooling_dependencies()`
5//! lists with dependencies that aren't tied to any single plugin.
6//!
7//! The catalogue is community-maintainable: the prefix and exact lists live in
8//! `crates/core/data/tooling.toml`, embedded via `include_str!` and parsed once
9//! at startup. There is no regeneration step. To add a tool, edit one entry in
10//! the TOML and open a PR. See `CONTRIBUTING.md`.
11
12use rustc_hash::FxHashSet;
13
14/// Embedded catalogue source. Because it is `include_str!`-embedded at compile
15/// time, a green `catalogue_parses` test guarantees the released binary parses.
16const CATALOGUE_TOML: &str = include_str!("../../data/tooling.toml");
17
18/// Framework-plugin name markers. A package whose bare name OR whose
19/// `@scope/`-stripped tail starts with one of these is a framework plugin and
20/// must NOT be listed in the catalogue (its config-parsing plugin credits it
21/// only when it actually appears in the config, avoiding a false negative). The
22/// tail check catches scoped community forms like
23/// `@ianvs/prettier-plugin-sort-imports`. Enforced by
24/// `catalogue_rejects_framework_plugin_exact_entries`.
25#[cfg(test)]
26const FRAMEWORK_PLUGIN_FAMILY_PREFIXES: &[&str] = &[
27    "vite-plugin-",
28    "prettier-plugin-",
29    "eslint-plugin-",
30    "rollup-plugin-",
31];
32
33/// Official scoped framework-plugin namespaces, checked against the FULL name.
34/// `@rollup/plugin-*` is Rollup's official plugin scope. This is deliberately
35/// NOT generalized to `@scope/plugin-*`, because `@vitejs/plugin-react` and
36/// peers are legitimately-kept tooling exacts.
37#[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    /// Match when `name.starts_with(pattern)`. Required and must be non-empty
51    /// (an empty pattern would match every package, disabling unused-dep
52    /// detection entirely).
53    pattern: String,
54    /// Optional human context; does not affect matching.
55    #[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    /// Exact package name to credit as tooling.
66    name: String,
67    /// Optional grouping label; does not affect matching.
68    #[expect(
69        dead_code,
70        reason = "documentation field, surfaced via the catalogue source"
71    )]
72    #[serde(default)]
73    ecosystem: Option<String>,
74}
75
76/// Parsed catalogue: ordered prefix patterns + an exact-match set.
77struct Catalogue {
78    prefixes: Vec<String>,
79    exact: FxHashSet<String>,
80}
81
82/// Parse and cache the embedded catalogue once. Panics with a clear message if
83/// the embedded TOML is malformed; this is unreachable in a released binary
84/// because the bytes are compile-time-embedded and gated by `catalogue_parses`.
85#[expect(
86    clippy::expect_used,
87    reason = "embedded tooling catalogue is compile-time data pinned by catalogue_parses"
88)]
89fn catalogue() -> &'static Catalogue {
90    static CATALOGUE: std::sync::OnceLock<Catalogue> = std::sync::OnceLock::new();
91    CATALOGUE.get_or_init(|| {
92        let parsed: ToolingCatalogue = toml::from_str(CATALOGUE_TOML).expect(
93            "embedded crates/core/data/tooling.toml must parse; run \
94             `cargo test -p fallow-core catalogue_parses` to see the error",
95        );
96        Catalogue {
97            prefixes: parsed.prefix.into_iter().map(|p| p.pattern).collect(),
98            exact: parsed.exact.into_iter().map(|e| e.name).collect(),
99        }
100    })
101}
102
103/// Check whether a package is a known tooling/dev dependency by name.
104///
105/// This is the single source of truth for general tooling detection.
106/// Per-plugin tooling dependencies are declared via `Plugin::tooling_dependencies()`
107/// and aggregated separately in `AggregatedPluginResult`.
108#[must_use]
109pub fn is_known_tooling_dependency(name: &str) -> bool {
110    let catalogue = catalogue();
111    catalogue
112        .prefixes
113        .iter()
114        .any(|p| name.starts_with(p.as_str()))
115        || catalogue.exact.contains(name)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn types_prefix_matches_scoped() {
124        assert!(is_known_tooling_dependency("@types/node"));
125        assert!(is_known_tooling_dependency("@types/react"));
126        assert!(is_known_tooling_dependency("@types/express"));
127    }
128
129    #[test]
130    fn types_prefix_does_not_match_similar_names() {
131        assert!(!is_known_tooling_dependency("type-fest"));
132        assert!(!is_known_tooling_dependency("typesafe-actions"));
133    }
134
135    #[test]
136    fn storybook_not_blanket_matched() {
137        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"));
151        assert!(!is_known_tooling_dependency("@babel/preset-env"));
152        assert!(!is_known_tooling_dependency("babel-loader"));
153        assert!(!is_known_tooling_dependency("babel-jest"));
154    }
155
156    #[test]
157    fn vitest_prefix_matches() {
158        assert!(is_known_tooling_dependency("@vitest/coverage-v8"));
159        assert!(is_known_tooling_dependency("@vitest/ui"));
160    }
161
162    #[test]
163    fn eslint_not_blanket_matched() {
164        assert!(!is_known_tooling_dependency("eslint"));
165        assert!(!is_known_tooling_dependency("eslint-plugin-react"));
166        assert!(!is_known_tooling_dependency("eslint-config-next"));
167        assert!(!is_known_tooling_dependency("@typescript-eslint/parser"));
168    }
169
170    #[test]
171    fn biomejs_prefix_matches() {
172        assert!(is_known_tooling_dependency("@biomejs/biome"));
173    }
174
175    #[test]
176    fn exact_typescript_matches() {
177        assert!(is_known_tooling_dependency("typescript"));
178    }
179
180    #[test]
181    fn exact_prettier_matches() {
182        assert!(is_known_tooling_dependency("prettier"));
183    }
184
185    #[test]
186    fn exact_vitest_matches() {
187        assert!(is_known_tooling_dependency("vitest"));
188    }
189
190    #[test]
191    fn exact_jest_matches() {
192        assert!(is_known_tooling_dependency("jest"));
193    }
194
195    #[test]
196    fn exact_vite_matches() {
197        assert!(is_known_tooling_dependency("vite"));
198    }
199
200    #[test]
201    fn exact_esbuild_matches() {
202        assert!(is_known_tooling_dependency("esbuild"));
203    }
204
205    #[test]
206    fn exact_tsup_matches() {
207        assert!(is_known_tooling_dependency("tsup"));
208    }
209
210    #[test]
211    fn exact_turbo_matches() {
212        assert!(is_known_tooling_dependency("turbo"));
213    }
214
215    #[test]
216    fn common_runtime_deps_not_tooling() {
217        assert!(!is_known_tooling_dependency("react"));
218        assert!(!is_known_tooling_dependency("react-dom"));
219        assert!(!is_known_tooling_dependency("express"));
220        assert!(!is_known_tooling_dependency("lodash"));
221        assert!(!is_known_tooling_dependency("next"));
222        assert!(!is_known_tooling_dependency("vue"));
223        assert!(!is_known_tooling_dependency("axios"));
224    }
225
226    #[test]
227    fn empty_string_not_tooling() {
228        assert!(!is_known_tooling_dependency(""));
229    }
230
231    #[test]
232    fn near_miss_not_tooling() {
233        assert!(!is_known_tooling_dependency("type-fest"));
234        assert!(!is_known_tooling_dependency("typestyle"));
235        assert!(!is_known_tooling_dependency("prettier-bytes")); // not the exact "prettier"
236    }
237
238    #[test]
239    fn sass_variants_are_tooling() {
240        assert!(is_known_tooling_dependency("sass"));
241        assert!(is_known_tooling_dependency("sass-embedded"));
242    }
243
244    #[test]
245    fn framework_plugin_packages_no_longer_exact_matched() {
246        assert!(!is_known_tooling_dependency("vite-plugin-svgr"));
247        assert!(!is_known_tooling_dependency("vite-plugin-eslint"));
248        assert!(!is_known_tooling_dependency("prettier-plugin-tailwindcss"));
249        assert!(!is_known_tooling_dependency(
250            "prettier-plugin-organize-imports"
251        ));
252        assert!(!is_known_tooling_dependency(
253            "@ianvs/prettier-plugin-sort-imports"
254        ));
255    }
256
257    #[test]
258    fn electron_forge_prefix_matches() {
259        assert!(is_known_tooling_dependency("@electron-forge/cli"));
260        assert!(is_known_tooling_dependency(
261            "@electron-forge/maker-squirrel"
262        ));
263    }
264
265    #[test]
266    fn electron_prefix_matches() {
267        assert!(is_known_tooling_dependency("@electron/rebuild"));
268        assert!(is_known_tooling_dependency("@electron/notarize"));
269    }
270
271    #[test]
272    fn formatjs_prefix_matches() {
273        assert!(is_known_tooling_dependency("@formatjs/cli"));
274        assert!(is_known_tooling_dependency("@formatjs/intl"));
275    }
276
277    #[test]
278    fn rollup_not_blanket_matched() {
279        assert!(!is_known_tooling_dependency("@rollup/plugin-commonjs"));
280        assert!(!is_known_tooling_dependency("@rollup/plugin-node-resolve"));
281        assert!(!is_known_tooling_dependency("@rollup/plugin-typescript"));
282    }
283
284    #[test]
285    fn semantic_release_prefix_matches() {
286        assert!(is_known_tooling_dependency("@semantic-release/github"));
287        assert!(is_known_tooling_dependency("@semantic-release/npm"));
288        assert!(is_known_tooling_dependency("semantic-release"));
289    }
290
291    #[test]
292    fn release_it_prefix_matches() {
293        assert!(is_known_tooling_dependency(
294            "@release-it/conventional-changelog"
295        ));
296    }
297
298    #[test]
299    fn lerna_lite_prefix_matches() {
300        assert!(is_known_tooling_dependency("@lerna-lite/cli"));
301        assert!(is_known_tooling_dependency("@lerna-lite/publish"));
302    }
303
304    #[test]
305    fn changesets_prefix_matches() {
306        assert!(is_known_tooling_dependency("@changesets/cli"));
307        assert!(is_known_tooling_dependency("@changesets/changelog-github"));
308    }
309
310    #[test]
311    fn graphql_codegen_prefix_matches() {
312        assert!(is_known_tooling_dependency("@graphql-codegen/cli"));
313        assert!(is_known_tooling_dependency(
314            "@graphql-codegen/typescript-operations"
315        ));
316    }
317
318    #[test]
319    fn secretlint_prefix_matches() {
320        assert!(is_known_tooling_dependency("secretlint"));
321        assert!(is_known_tooling_dependency(
322            "@secretlint/secretlint-rule-preset-recommend"
323        ));
324    }
325
326    #[test]
327    fn oxlint_prefix_matches() {
328        assert!(is_known_tooling_dependency("oxlint"));
329    }
330
331    #[test]
332    fn react_native_community_prefix_matches() {
333        assert!(is_known_tooling_dependency("@react-native-community/cli"));
334        assert!(is_known_tooling_dependency(
335            "@react-native-community/cli-platform-android"
336        ));
337    }
338
339    #[test]
340    fn react_native_prefix_matches() {
341        assert!(is_known_tooling_dependency("@react-native/metro-config"));
342        assert!(is_known_tooling_dependency(
343            "@react-native/typescript-config"
344        ));
345    }
346
347    #[test]
348    fn jest_prefix_matches() {
349        assert!(is_known_tooling_dependency("@jest/globals"));
350        assert!(is_known_tooling_dependency("@jest/types"));
351    }
352
353    #[test]
354    fn playwright_prefix_matches() {
355        assert!(is_known_tooling_dependency("@playwright/test"));
356        assert!(is_known_tooling_dependency("playwright"));
357    }
358
359    #[test]
360    fn tapjs_prefix_matches() {
361        assert!(is_known_tooling_dependency("@tapjs/test"));
362        assert!(is_known_tooling_dependency("@tapjs/snapshot"));
363    }
364
365    #[test]
366    fn exact_tap_matches() {
367        assert!(is_known_tooling_dependency("tap"));
368    }
369
370    #[test]
371    fn exact_rolldown_matches() {
372        assert!(is_known_tooling_dependency("rolldown"));
373        assert!(is_known_tooling_dependency("rolldown-vite"));
374    }
375
376    #[test]
377    fn exact_electron_matches() {
378        assert!(is_known_tooling_dependency("electron"));
379        assert!(is_known_tooling_dependency("electron-builder"));
380        assert!(is_known_tooling_dependency("electron-vite"));
381    }
382
383    #[test]
384    fn exact_sharp_matches() {
385        assert!(is_known_tooling_dependency("sharp"));
386    }
387
388    #[test]
389    fn exact_puppeteer_matches() {
390        assert!(is_known_tooling_dependency("puppeteer"));
391    }
392
393    #[test]
394    fn exact_madge_matches() {
395        assert!(is_known_tooling_dependency("madge"));
396    }
397
398    #[test]
399    fn exact_patch_package_matches() {
400        assert!(is_known_tooling_dependency("patch-package"));
401    }
402
403    #[test]
404    fn exact_nx_matches() {
405        assert!(is_known_tooling_dependency("nx"));
406    }
407
408    #[test]
409    fn exact_vue_tsc_matches() {
410        assert!(is_known_tooling_dependency("vue-tsc"));
411    }
412
413    #[test]
414    fn exact_tsconfig_packages_match() {
415        assert!(is_known_tooling_dependency("@tsconfig/node20"));
416        assert!(is_known_tooling_dependency("@tsconfig/react-native"));
417        assert!(is_known_tooling_dependency("@vue/tsconfig"));
418    }
419
420    #[test]
421    fn exact_vitejs_plugins_match() {
422        assert!(is_known_tooling_dependency("@vitejs/plugin-vue"));
423        assert!(is_known_tooling_dependency("@vitejs/plugin-react"));
424        assert!(is_known_tooling_dependency("@vitejs/plugin-react-swc"));
425        assert!(is_known_tooling_dependency("@vitejs/plugin-legacy"));
426    }
427
428    #[test]
429    fn exact_oxc_transform_matches() {
430        assert!(is_known_tooling_dependency("oxc-transform"));
431    }
432
433    #[test]
434    fn exact_typescript_native_preview_matches() {
435        assert!(is_known_tooling_dependency("@typescript/native-preview"));
436    }
437
438    #[test]
439    fn exact_tw_animate_css_matches() {
440        assert!(is_known_tooling_dependency("tw-animate-css"));
441    }
442
443    #[test]
444    fn exact_manypkg_cli_matches() {
445        assert!(is_known_tooling_dependency("@manypkg/cli"));
446    }
447
448    #[test]
449    fn exact_swc_variants_match() {
450        assert!(is_known_tooling_dependency("@swc/core"));
451        assert!(is_known_tooling_dependency("@swc/jest"));
452    }
453
454    #[test]
455    fn runtime_deps_with_similar_names_not_tooling() {
456        assert!(!is_known_tooling_dependency("react-scripts"));
457        assert!(!is_known_tooling_dependency("express-validator"));
458        assert!(!is_known_tooling_dependency("sass-loader")); // "sass" is exact, not prefix
459    }
460
461    #[test]
462    fn postcss_not_blanket_matched() {
463        assert!(!is_known_tooling_dependency("postcss-modules"));
464        assert!(!is_known_tooling_dependency("postcss-import"));
465        assert!(!is_known_tooling_dependency("autoprefixer"));
466        assert!(!is_known_tooling_dependency("tailwindcss"));
467        assert!(!is_known_tooling_dependency("@tailwindcss/typography"));
468    }
469
470    #[test]
471    fn catalogue_is_deterministic() {
472        assert_eq!(
473            is_known_tooling_dependency("typescript"),
474            is_known_tooling_dependency("typescript")
475        );
476        assert!(is_known_tooling_dependency("typescript"));
477    }
478
479    #[test]
480    fn catalogue_parses() {
481        let cat = catalogue();
482        assert!(!cat.prefixes.is_empty(), "catalogue must have prefixes");
483        assert!(!cat.exact.is_empty(), "catalogue must have exact entries");
484        assert!(cat.exact.contains("typescript"));
485        assert!(cat.prefixes.iter().any(|p| p == "@types/"));
486    }
487
488    #[test]
489    fn catalogue_has_no_empty_or_whitespace_prefixes() {
490        for prefix in &catalogue().prefixes {
491            assert!(
492                !prefix.trim().is_empty(),
493                "catalogue prefix must be non-empty / non-whitespace; got {prefix:?}"
494            );
495        }
496    }
497
498    #[test]
499    fn catalogue_has_no_duplicate_entries() {
500        let parsed: ToolingCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
501
502        let mut seen_exact = FxHashSet::default();
503        for entry in &parsed.exact {
504            assert!(
505                seen_exact.insert(entry.name.as_str()),
506                "duplicate exact catalogue entry: {:?}",
507                entry.name
508            );
509        }
510
511        let mut seen_prefix = FxHashSet::default();
512        for entry in &parsed.prefix {
513            assert!(
514                seen_prefix.insert(entry.pattern.as_str()),
515                "duplicate prefix catalogue entry: {:?}",
516                entry.pattern
517            );
518        }
519    }
520
521    #[test]
522    fn catalogue_rejects_framework_plugin_exact_entries() {
523        let parsed: ToolingCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
524        for entry in &parsed.exact {
525            let tail = entry
526                .name
527                .strip_prefix('@')
528                .and_then(|rest| rest.split_once('/'))
529                .map(|(_scope, tail)| tail);
530            for bad in FRAMEWORK_PLUGIN_FAMILY_PREFIXES {
531                assert!(
532                    !entry.name.starts_with(bad) && !tail.is_some_and(|t| t.starts_with(bad)),
533                    "exact catalogue entry {:?} is a framework plugin ({bad}); \
534                     credit it in the relevant plugin's config parser instead of the catalogue",
535                    entry.name,
536                );
537            }
538            for bad in FRAMEWORK_PLUGIN_SCOPED_PREFIXES {
539                assert!(
540                    !entry.name.starts_with(bad),
541                    "exact catalogue entry {:?} is a framework plugin ({bad}); \
542                     credit it in the relevant plugin's config parser instead of the catalogue",
543                    entry.name,
544                );
545            }
546        }
547    }
548}