fallow_core/plugins/registry/
mod.rs1use rustc_hash::FxHashSet;
4use std::path::{Path, PathBuf};
5
6use fallow_config::{EntryPointRole, ExternalPluginDef, PackageJson, UsedClassMemberRule};
7
8use super::{PathRule, Plugin, PluginUsedExportRule};
9
10pub(crate) mod builtin;
11mod helpers;
12
13use helpers::{
14 check_has_config_file, discover_config_files, process_config_result, process_external_plugins,
15 process_static_patterns,
16};
17
18fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
23 matches!(
24 plugin_name,
25 "eslint" | "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 virtual_package_suffixes: Vec<String>,
68 pub generated_import_patterns: Vec<String>,
71 pub path_aliases: Vec<(String, String)>,
74 pub active_plugins: Vec<String>,
76 pub fixture_patterns: Vec<(String, String)>,
78 pub scss_include_paths: Vec<PathBuf>,
83}
84
85impl PluginRegistry {
86 #[must_use]
88 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
89 Self {
90 plugins: builtin::create_builtin_plugins(),
91 external_plugins: external,
92 }
93 }
94
95 pub fn run(
100 &self,
101 pkg: &PackageJson,
102 root: &Path,
103 discovered_files: &[PathBuf],
104 ) -> AggregatedPluginResult {
105 self.run_with_search_roots(pkg, root, discovered_files, &[root])
106 }
107
108 pub fn run_with_search_roots(
115 &self,
116 pkg: &PackageJson,
117 root: &Path,
118 discovered_files: &[PathBuf],
119 config_search_roots: &[&Path],
120 ) -> AggregatedPluginResult {
121 let _span = tracing::info_span!("run_plugins").entered();
122 let mut result = AggregatedPluginResult::default();
123
124 let all_deps = pkg.all_dependency_names();
127 let active: Vec<&dyn Plugin> = self
128 .plugins
129 .iter()
130 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
131 .map(AsRef::as_ref)
132 .collect();
133
134 tracing::info!(
135 plugins = active
136 .iter()
137 .map(|p| p.name())
138 .collect::<Vec<_>>()
139 .join(", "),
140 "active plugins"
141 );
142
143 check_meta_framework_prerequisites(&active, root);
146
147 for plugin in &active {
149 process_static_patterns(*plugin, root, &mut result);
150 }
151
152 process_external_plugins(
154 &self.external_plugins,
155 &all_deps,
156 root,
157 discovered_files,
158 &mut result,
159 );
160
161 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
164 .iter()
165 .filter(|p| !p.config_patterns().is_empty())
166 .map(|p| {
167 let matchers: Vec<globset::GlobMatcher> = p
168 .config_patterns()
169 .iter()
170 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
171 .collect();
172 (*p, matchers)
173 })
174 .collect();
175
176 let needs_relative_files = !config_matchers.is_empty()
181 || active.iter().any(|p| p.package_json_config_key().is_some());
182 let relative_files: Vec<(PathBuf, String)> = if needs_relative_files {
183 discovered_files
184 .iter()
185 .map(|f| {
186 let rel = f
187 .strip_prefix(root)
188 .unwrap_or(f)
189 .to_string_lossy()
190 .into_owned();
191 (f.clone(), rel)
192 })
193 .collect()
194 } else {
195 Vec::new()
196 };
197
198 if !config_matchers.is_empty() {
199 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
201
202 for (plugin, matchers) in &config_matchers {
203 for (abs_path, rel_path) in &relative_files {
204 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
205 && let Ok(source) = std::fs::read_to_string(abs_path)
206 {
207 let plugin_result = plugin.resolve_config(abs_path, &source, root);
208 if !plugin_result.is_empty() {
209 resolved_plugins.insert(plugin.name());
210 tracing::debug!(
211 plugin = plugin.name(),
212 config = rel_path.as_str(),
213 entries = plugin_result.entry_patterns.len(),
214 deps = plugin_result.referenced_dependencies.len(),
215 "resolved config"
216 );
217 process_config_result(plugin.name(), plugin_result, &mut result);
218 }
219 }
220 }
221 }
222
223 let json_configs =
227 discover_config_files(&config_matchers, &resolved_plugins, config_search_roots);
228 for (abs_path, plugin) in &json_configs {
229 if let Ok(source) = std::fs::read_to_string(abs_path) {
230 let plugin_result = plugin.resolve_config(abs_path, &source, root);
231 if !plugin_result.is_empty() {
232 let rel = abs_path
233 .strip_prefix(root)
234 .map(|p| p.to_string_lossy())
235 .unwrap_or_default();
236 tracing::debug!(
237 plugin = plugin.name(),
238 config = %rel,
239 entries = plugin_result.entry_patterns.len(),
240 deps = plugin_result.referenced_dependencies.len(),
241 "resolved config (filesystem fallback)"
242 );
243 process_config_result(plugin.name(), plugin_result, &mut result);
244 }
245 }
246 }
247 }
248
249 for plugin in &active {
253 if let Some(key) = plugin.package_json_config_key()
254 && !check_has_config_file(*plugin, &config_matchers, &relative_files)
255 {
256 let pkg_path = root.join("package.json");
258 if let Ok(content) = std::fs::read_to_string(&pkg_path)
259 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
260 && let Some(config_value) = json.get(key)
261 {
262 let config_json = serde_json::to_string(config_value).unwrap_or_default();
263 let fake_path = root.join(format!("{key}.config.json"));
264 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
265 if !plugin_result.is_empty() {
266 tracing::debug!(
267 plugin = plugin.name(),
268 key = key,
269 "resolved inline package.json config"
270 );
271 process_config_result(plugin.name(), plugin_result, &mut result);
272 }
273 }
274 }
275 }
276
277 result
278 }
279
280 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 let workspace_files: Vec<PathBuf> = relative_files
307 .iter()
308 .map(|(abs_path, _)| abs_path.clone())
309 .collect();
310
311 tracing::info!(
312 plugins = active
313 .iter()
314 .map(|p| p.name())
315 .collect::<Vec<_>>()
316 .join(", "),
317 "active plugins"
318 );
319
320 process_external_plugins(
321 &self.external_plugins,
322 &all_deps,
323 root,
324 &workspace_files,
325 &mut result,
326 );
327
328 if active.is_empty() && result.active_plugins.is_empty() {
330 return result;
331 }
332
333 for plugin in &active {
335 process_static_patterns(*plugin, root, &mut result);
336 }
337
338 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
341 let workspace_matchers: Vec<_> = precompiled_config_matchers
342 .iter()
343 .filter(|(p, _)| {
344 active_names.contains(p.name())
345 && (!skip_config_plugins.contains(p.name())
346 || must_parse_workspace_config_when_root_active(p.name()))
347 })
348 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
349 .collect();
350
351 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
352 if !workspace_matchers.is_empty() {
353 for (plugin, matchers) in &workspace_matchers {
354 for (abs_path, rel_path) in relative_files {
355 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
356 && let Ok(source) = std::fs::read_to_string(abs_path)
357 {
358 let plugin_result = plugin.resolve_config(abs_path, &source, root);
359 if !plugin_result.is_empty() {
360 resolved_ws_plugins.insert(plugin.name());
361 tracing::debug!(
362 plugin = plugin.name(),
363 config = rel_path.as_str(),
364 entries = plugin_result.entry_patterns.len(),
365 deps = plugin_result.referenced_dependencies.len(),
366 "resolved config"
367 );
368 process_config_result(plugin.name(), plugin_result, &mut result);
369 }
370 }
371 }
372 }
373 }
374
375 let ws_json_configs = if root == project_root {
380 discover_config_files(&workspace_matchers, &resolved_ws_plugins, &[root])
381 } else {
382 discover_config_files(
383 &workspace_matchers,
384 &resolved_ws_plugins,
385 &[root, project_root],
386 )
387 };
388 for (abs_path, plugin) in &ws_json_configs {
390 if let Ok(source) = std::fs::read_to_string(abs_path) {
391 let plugin_result = plugin.resolve_config(abs_path, &source, root);
392 if !plugin_result.is_empty() {
393 let rel = abs_path
394 .strip_prefix(project_root)
395 .map(|p| p.to_string_lossy())
396 .unwrap_or_default();
397 tracing::debug!(
398 plugin = plugin.name(),
399 config = %rel,
400 entries = plugin_result.entry_patterns.len(),
401 deps = plugin_result.referenced_dependencies.len(),
402 "resolved config (workspace filesystem fallback)"
403 );
404 process_config_result(plugin.name(), plugin_result, &mut result);
405 }
406 }
407 }
408
409 result
410 }
411
412 #[must_use]
415 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
416 self.plugins
417 .iter()
418 .filter(|p| !p.config_patterns().is_empty())
419 .map(|p| {
420 let matchers: Vec<globset::GlobMatcher> = p
421 .config_patterns()
422 .iter()
423 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
424 .collect();
425 (p.as_ref(), matchers)
426 })
427 .collect()
428 }
429}
430
431impl Default for PluginRegistry {
432 fn default() -> Self {
433 Self::new(vec![])
434 }
435}
436
437fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
443 for plugin in active_plugins {
444 match plugin.name() {
445 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => {
446 tracing::warn!(
447 "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
448 before fallow for accurate analysis"
449 );
450 }
451 "astro" if !root.join(".astro").exists() => {
452 tracing::warn!(
453 "Astro project missing .astro/ types: run `astro sync` \
454 before fallow for accurate analysis"
455 );
456 }
457 _ => {}
458 }
459 }
460}
461
462#[cfg(test)]
463mod tests;