fallow_core/plugins/registry/
mod.rs1#![expect(
3 clippy::excessive_nesting,
4 reason = "plugin config parsing requires deep AST matching"
5)]
6
7use rustc_hash::FxHashSet;
8use std::path::{Path, PathBuf};
9
10use fallow_config::{EntryPointRole, ExternalPluginDef, PackageJson, UsedClassMemberRule};
11
12use super::{PathRule, Plugin, PluginUsedExportRule};
13
14pub(crate) mod builtin;
15mod helpers;
16
17use helpers::{
18 check_has_config_file, discover_config_files, process_config_result, process_external_plugins,
19 process_static_patterns,
20};
21
22fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
23 matches!(
24 plugin_name,
25 "docusaurus" | "jest" | "tanstack-router" | "vitest"
26 )
27}
28
29pub struct PluginRegistry {
31 plugins: Vec<Box<dyn Plugin>>,
32 external_plugins: Vec<ExternalPluginDef>,
33}
34
35#[derive(Debug, Default)]
37pub struct AggregatedPluginResult {
38 pub entry_patterns: Vec<(PathRule, String)>,
40 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
42 pub config_patterns: Vec<String>,
44 pub always_used: Vec<(String, String)>,
46 pub used_exports: Vec<PluginUsedExportRule>,
48 pub used_class_members: Vec<UsedClassMemberRule>,
52 pub referenced_dependencies: Vec<String>,
54 pub discovered_always_used: Vec<(String, String)>,
56 pub setup_files: Vec<(PathBuf, String)>,
58 pub tooling_dependencies: Vec<String>,
60 pub script_used_packages: FxHashSet<String>,
62 pub virtual_module_prefixes: Vec<String>,
65 pub generated_import_patterns: Vec<String>,
68 pub path_aliases: Vec<(String, String)>,
71 pub active_plugins: Vec<String>,
73 pub fixture_patterns: Vec<(String, String)>,
75 pub scss_include_paths: Vec<PathBuf>,
80}
81
82impl PluginRegistry {
83 #[must_use]
85 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
86 Self {
87 plugins: builtin::create_builtin_plugins(),
88 external_plugins: external,
89 }
90 }
91
92 pub fn run(
97 &self,
98 pkg: &PackageJson,
99 root: &Path,
100 discovered_files: &[PathBuf],
101 ) -> AggregatedPluginResult {
102 self.run_with_search_roots(pkg, root, discovered_files, &[root])
103 }
104
105 pub fn run_with_search_roots(
112 &self,
113 pkg: &PackageJson,
114 root: &Path,
115 discovered_files: &[PathBuf],
116 config_search_roots: &[&Path],
117 ) -> AggregatedPluginResult {
118 let _span = tracing::info_span!("run_plugins").entered();
119 let mut result = AggregatedPluginResult::default();
120
121 let all_deps = pkg.all_dependency_names();
124 let active: Vec<&dyn Plugin> = self
125 .plugins
126 .iter()
127 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
128 .map(AsRef::as_ref)
129 .collect();
130
131 tracing::info!(
132 plugins = active
133 .iter()
134 .map(|p| p.name())
135 .collect::<Vec<_>>()
136 .join(", "),
137 "active plugins"
138 );
139
140 check_meta_framework_prerequisites(&active, root);
143
144 for plugin in &active {
146 process_static_patterns(*plugin, root, &mut result);
147 }
148
149 process_external_plugins(
151 &self.external_plugins,
152 &all_deps,
153 root,
154 discovered_files,
155 &mut result,
156 );
157
158 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
161 .iter()
162 .filter(|p| !p.config_patterns().is_empty())
163 .map(|p| {
164 let matchers: Vec<globset::GlobMatcher> = p
165 .config_patterns()
166 .iter()
167 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
168 .collect();
169 (*p, matchers)
170 })
171 .collect();
172
173 let needs_relative_files = !config_matchers.is_empty()
178 || active.iter().any(|p| p.package_json_config_key().is_some());
179 let relative_files: Vec<(&PathBuf, String)> = if needs_relative_files {
180 discovered_files
181 .iter()
182 .map(|f| {
183 let rel = f
184 .strip_prefix(root)
185 .unwrap_or(f)
186 .to_string_lossy()
187 .into_owned();
188 (f, rel)
189 })
190 .collect()
191 } else {
192 Vec::new()
193 };
194
195 if !config_matchers.is_empty() {
196 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
198
199 for (plugin, matchers) in &config_matchers {
200 for (abs_path, rel_path) in &relative_files {
201 if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
202 resolved_plugins.insert(plugin.name());
205 if let Ok(source) = std::fs::read_to_string(abs_path) {
206 let plugin_result = plugin.resolve_config(abs_path, &source, root);
207 if !plugin_result.is_empty() {
208 tracing::debug!(
209 plugin = plugin.name(),
210 config = rel_path.as_str(),
211 entries = plugin_result.entry_patterns.len(),
212 deps = plugin_result.referenced_dependencies.len(),
213 "resolved config"
214 );
215 process_config_result(plugin.name(), plugin_result, &mut result);
216 }
217 }
218 }
219 }
220 }
221
222 let json_configs =
226 discover_config_files(&config_matchers, &resolved_plugins, config_search_roots);
227 for (abs_path, plugin) in &json_configs {
228 if let Ok(source) = std::fs::read_to_string(abs_path) {
229 let plugin_result = plugin.resolve_config(abs_path, &source, root);
230 if !plugin_result.is_empty() {
231 let rel = abs_path
232 .strip_prefix(root)
233 .map(|p| p.to_string_lossy())
234 .unwrap_or_default();
235 tracing::debug!(
236 plugin = plugin.name(),
237 config = %rel,
238 entries = plugin_result.entry_patterns.len(),
239 deps = plugin_result.referenced_dependencies.len(),
240 "resolved config (filesystem fallback)"
241 );
242 process_config_result(plugin.name(), plugin_result, &mut result);
243 }
244 }
245 }
246 }
247
248 for plugin in &active {
252 if let Some(key) = plugin.package_json_config_key()
253 && !check_has_config_file(*plugin, &config_matchers, &relative_files)
254 {
255 let pkg_path = root.join("package.json");
257 if let Ok(content) = std::fs::read_to_string(&pkg_path)
258 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
259 && let Some(config_value) = json.get(key)
260 {
261 let config_json = serde_json::to_string(config_value).unwrap_or_default();
262 let fake_path = root.join(format!("{key}.config.json"));
263 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
264 if !plugin_result.is_empty() {
265 tracing::debug!(
266 plugin = plugin.name(),
267 key = key,
268 "resolved inline package.json config"
269 );
270 process_config_result(plugin.name(), plugin_result, &mut result);
271 }
272 }
273 }
274 }
275
276 result
277 }
278
279 pub fn run_workspace_fast(
286 &self,
287 pkg: &PackageJson,
288 root: &Path,
289 project_root: &Path,
290 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
291 relative_files: &[(&PathBuf, String)],
292 skip_config_plugins: &FxHashSet<&str>,
293 ) -> AggregatedPluginResult {
294 let _span = tracing::info_span!("run_plugins").entered();
295 let mut result = AggregatedPluginResult::default();
296
297 let all_deps = pkg.all_dependency_names();
299 let active: Vec<&dyn Plugin> = self
300 .plugins
301 .iter()
302 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
303 .map(AsRef::as_ref)
304 .collect();
305
306 tracing::info!(
307 plugins = active
308 .iter()
309 .map(|p| p.name())
310 .collect::<Vec<_>>()
311 .join(", "),
312 "active plugins"
313 );
314
315 if active.is_empty() {
317 return result;
318 }
319
320 for plugin in &active {
322 process_static_patterns(*plugin, root, &mut result);
323 }
324
325 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
328 let workspace_matchers: Vec<_> = precompiled_config_matchers
329 .iter()
330 .filter(|(p, _)| {
331 active_names.contains(p.name())
332 && (!skip_config_plugins.contains(p.name())
333 || must_parse_workspace_config_when_root_active(p.name()))
334 })
335 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
336 .collect();
337
338 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
339 if !workspace_matchers.is_empty() {
340 for (plugin, matchers) in &workspace_matchers {
341 for (abs_path, rel_path) in relative_files {
342 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
343 && let Ok(source) = std::fs::read_to_string(abs_path)
344 {
345 resolved_ws_plugins.insert(plugin.name());
348 let plugin_result = plugin.resolve_config(abs_path, &source, root);
349 if !plugin_result.is_empty() {
350 tracing::debug!(
351 plugin = plugin.name(),
352 config = rel_path.as_str(),
353 entries = plugin_result.entry_patterns.len(),
354 deps = plugin_result.referenced_dependencies.len(),
355 "resolved config"
356 );
357 process_config_result(plugin.name(), plugin_result, &mut result);
358 }
359 }
360 }
361 }
362 }
363
364 let ws_json_configs = if root == project_root {
369 discover_config_files(&workspace_matchers, &resolved_ws_plugins, &[root])
370 } else {
371 discover_config_files(
372 &workspace_matchers,
373 &resolved_ws_plugins,
374 &[root, project_root],
375 )
376 };
377 for (abs_path, plugin) in &ws_json_configs {
379 if let Ok(source) = std::fs::read_to_string(abs_path) {
380 let plugin_result = plugin.resolve_config(abs_path, &source, root);
381 if !plugin_result.is_empty() {
382 let rel = abs_path
383 .strip_prefix(project_root)
384 .map(|p| p.to_string_lossy())
385 .unwrap_or_default();
386 tracing::debug!(
387 plugin = plugin.name(),
388 config = %rel,
389 entries = plugin_result.entry_patterns.len(),
390 deps = plugin_result.referenced_dependencies.len(),
391 "resolved config (workspace filesystem fallback)"
392 );
393 process_config_result(plugin.name(), plugin_result, &mut result);
394 }
395 }
396 }
397
398 result
399 }
400
401 #[must_use]
404 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
405 self.plugins
406 .iter()
407 .filter(|p| !p.config_patterns().is_empty())
408 .map(|p| {
409 let matchers: Vec<globset::GlobMatcher> = p
410 .config_patterns()
411 .iter()
412 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
413 .collect();
414 (p.as_ref(), matchers)
415 })
416 .collect()
417 }
418}
419
420impl Default for PluginRegistry {
421 fn default() -> Self {
422 Self::new(vec![])
423 }
424}
425
426fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
432 for plugin in active_plugins {
433 match plugin.name() {
434 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => {
435 tracing::warn!(
436 "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
437 before fallow for accurate analysis"
438 );
439 }
440 "astro" if !root.join(".astro").exists() => {
441 tracing::warn!(
442 "Astro project missing .astro/ types: run `astro sync` \
443 before fallow for accurate analysis"
444 );
445 }
446 _ => {}
447 }
448 }
449}
450
451#[cfg(test)]
452mod tests;