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 {
19 matches!(
20 plugin_name,
21 "docusaurus" | "jest" | "tanstack-router" | "vitest"
22 )
23}
24
25pub struct PluginRegistry {
27 plugins: Vec<Box<dyn Plugin>>,
28 external_plugins: Vec<ExternalPluginDef>,
29}
30
31#[derive(Debug, Default)]
33pub struct AggregatedPluginResult {
34 pub entry_patterns: Vec<(PathRule, String)>,
36 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
38 pub config_patterns: Vec<String>,
40 pub always_used: Vec<(String, String)>,
42 pub used_exports: Vec<PluginUsedExportRule>,
44 pub used_class_members: Vec<UsedClassMemberRule>,
48 pub referenced_dependencies: Vec<String>,
50 pub discovered_always_used: Vec<(String, String)>,
52 pub setup_files: Vec<(PathBuf, String)>,
54 pub tooling_dependencies: Vec<String>,
56 pub script_used_packages: FxHashSet<String>,
58 pub virtual_module_prefixes: Vec<String>,
61 pub generated_import_patterns: Vec<String>,
64 pub path_aliases: Vec<(String, String)>,
67 pub active_plugins: Vec<String>,
69 pub fixture_patterns: Vec<(String, String)>,
71 pub scss_include_paths: Vec<PathBuf>,
76}
77
78impl PluginRegistry {
79 #[must_use]
81 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
82 Self {
83 plugins: builtin::create_builtin_plugins(),
84 external_plugins: external,
85 }
86 }
87
88 pub fn run(
93 &self,
94 pkg: &PackageJson,
95 root: &Path,
96 discovered_files: &[PathBuf],
97 ) -> AggregatedPluginResult {
98 self.run_with_search_roots(pkg, root, discovered_files, &[root])
99 }
100
101 pub fn run_with_search_roots(
108 &self,
109 pkg: &PackageJson,
110 root: &Path,
111 discovered_files: &[PathBuf],
112 config_search_roots: &[&Path],
113 ) -> AggregatedPluginResult {
114 let _span = tracing::info_span!("run_plugins").entered();
115 let mut result = AggregatedPluginResult::default();
116
117 let all_deps = pkg.all_dependency_names();
120 let active: Vec<&dyn Plugin> = self
121 .plugins
122 .iter()
123 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
124 .map(AsRef::as_ref)
125 .collect();
126
127 tracing::info!(
128 plugins = active
129 .iter()
130 .map(|p| p.name())
131 .collect::<Vec<_>>()
132 .join(", "),
133 "active plugins"
134 );
135
136 check_meta_framework_prerequisites(&active, root);
139
140 for plugin in &active {
142 process_static_patterns(*plugin, root, &mut result);
143 }
144
145 process_external_plugins(
147 &self.external_plugins,
148 &all_deps,
149 root,
150 discovered_files,
151 &mut result,
152 );
153
154 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
157 .iter()
158 .filter(|p| !p.config_patterns().is_empty())
159 .map(|p| {
160 let matchers: Vec<globset::GlobMatcher> = p
161 .config_patterns()
162 .iter()
163 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
164 .collect();
165 (*p, matchers)
166 })
167 .collect();
168
169 let needs_relative_files = !config_matchers.is_empty()
174 || active.iter().any(|p| p.package_json_config_key().is_some());
175 let relative_files: Vec<(PathBuf, String)> = if needs_relative_files {
176 discovered_files
177 .iter()
178 .map(|f| {
179 let rel = f
180 .strip_prefix(root)
181 .unwrap_or(f)
182 .to_string_lossy()
183 .into_owned();
184 (f.clone(), rel)
185 })
186 .collect()
187 } else {
188 Vec::new()
189 };
190
191 if !config_matchers.is_empty() {
192 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
194
195 for (plugin, matchers) in &config_matchers {
196 for (abs_path, rel_path) in &relative_files {
197 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
198 && let Ok(source) = std::fs::read_to_string(abs_path)
199 {
200 let plugin_result = plugin.resolve_config(abs_path, &source, root);
201 if !plugin_result.is_empty() {
202 resolved_plugins.insert(plugin.name());
203 tracing::debug!(
204 plugin = plugin.name(),
205 config = rel_path.as_str(),
206 entries = plugin_result.entry_patterns.len(),
207 deps = plugin_result.referenced_dependencies.len(),
208 "resolved config"
209 );
210 process_config_result(plugin.name(), plugin_result, &mut result);
211 }
212 }
213 }
214 }
215
216 let json_configs =
220 discover_config_files(&config_matchers, &resolved_plugins, config_search_roots);
221 for (abs_path, plugin) in &json_configs {
222 if let Ok(source) = std::fs::read_to_string(abs_path) {
223 let plugin_result = plugin.resolve_config(abs_path, &source, root);
224 if !plugin_result.is_empty() {
225 let rel = abs_path
226 .strip_prefix(root)
227 .map(|p| p.to_string_lossy())
228 .unwrap_or_default();
229 tracing::debug!(
230 plugin = plugin.name(),
231 config = %rel,
232 entries = plugin_result.entry_patterns.len(),
233 deps = plugin_result.referenced_dependencies.len(),
234 "resolved config (filesystem fallback)"
235 );
236 process_config_result(plugin.name(), plugin_result, &mut result);
237 }
238 }
239 }
240 }
241
242 for plugin in &active {
246 if let Some(key) = plugin.package_json_config_key()
247 && !check_has_config_file(*plugin, &config_matchers, &relative_files)
248 {
249 let pkg_path = root.join("package.json");
251 if let Ok(content) = std::fs::read_to_string(&pkg_path)
252 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
253 && let Some(config_value) = json.get(key)
254 {
255 let config_json = serde_json::to_string(config_value).unwrap_or_default();
256 let fake_path = root.join(format!("{key}.config.json"));
257 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
258 if !plugin_result.is_empty() {
259 tracing::debug!(
260 plugin = plugin.name(),
261 key = key,
262 "resolved inline package.json config"
263 );
264 process_config_result(plugin.name(), plugin_result, &mut result);
265 }
266 }
267 }
268 }
269
270 result
271 }
272
273 pub fn run_workspace_fast(
280 &self,
281 pkg: &PackageJson,
282 root: &Path,
283 project_root: &Path,
284 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
285 relative_files: &[(PathBuf, String)],
286 skip_config_plugins: &FxHashSet<&str>,
287 ) -> AggregatedPluginResult {
288 let _span = tracing::info_span!("run_plugins").entered();
289 let mut result = AggregatedPluginResult::default();
290
291 let all_deps = pkg.all_dependency_names();
293 let active: Vec<&dyn Plugin> = self
294 .plugins
295 .iter()
296 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
297 .map(AsRef::as_ref)
298 .collect();
299
300 tracing::info!(
301 plugins = active
302 .iter()
303 .map(|p| p.name())
304 .collect::<Vec<_>>()
305 .join(", "),
306 "active plugins"
307 );
308
309 if active.is_empty() {
311 return result;
312 }
313
314 for plugin in &active {
316 process_static_patterns(*plugin, root, &mut result);
317 }
318
319 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
322 let workspace_matchers: Vec<_> = precompiled_config_matchers
323 .iter()
324 .filter(|(p, _)| {
325 active_names.contains(p.name())
326 && (!skip_config_plugins.contains(p.name())
327 || must_parse_workspace_config_when_root_active(p.name()))
328 })
329 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
330 .collect();
331
332 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
333 if !workspace_matchers.is_empty() {
334 for (plugin, matchers) in &workspace_matchers {
335 for (abs_path, rel_path) in relative_files {
336 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
337 && let Ok(source) = std::fs::read_to_string(abs_path)
338 {
339 let plugin_result = plugin.resolve_config(abs_path, &source, root);
340 if !plugin_result.is_empty() {
341 resolved_ws_plugins.insert(plugin.name());
342 tracing::debug!(
343 plugin = plugin.name(),
344 config = rel_path.as_str(),
345 entries = plugin_result.entry_patterns.len(),
346 deps = plugin_result.referenced_dependencies.len(),
347 "resolved config"
348 );
349 process_config_result(plugin.name(), plugin_result, &mut result);
350 }
351 }
352 }
353 }
354 }
355
356 let ws_json_configs = if root == project_root {
361 discover_config_files(&workspace_matchers, &resolved_ws_plugins, &[root])
362 } else {
363 discover_config_files(
364 &workspace_matchers,
365 &resolved_ws_plugins,
366 &[root, project_root],
367 )
368 };
369 for (abs_path, plugin) in &ws_json_configs {
371 if let Ok(source) = std::fs::read_to_string(abs_path) {
372 let plugin_result = plugin.resolve_config(abs_path, &source, root);
373 if !plugin_result.is_empty() {
374 let rel = abs_path
375 .strip_prefix(project_root)
376 .map(|p| p.to_string_lossy())
377 .unwrap_or_default();
378 tracing::debug!(
379 plugin = plugin.name(),
380 config = %rel,
381 entries = plugin_result.entry_patterns.len(),
382 deps = plugin_result.referenced_dependencies.len(),
383 "resolved config (workspace filesystem fallback)"
384 );
385 process_config_result(plugin.name(), plugin_result, &mut result);
386 }
387 }
388 }
389
390 result
391 }
392
393 #[must_use]
396 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
397 self.plugins
398 .iter()
399 .filter(|p| !p.config_patterns().is_empty())
400 .map(|p| {
401 let matchers: Vec<globset::GlobMatcher> = p
402 .config_patterns()
403 .iter()
404 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
405 .collect();
406 (p.as_ref(), matchers)
407 })
408 .collect()
409 }
410}
411
412impl Default for PluginRegistry {
413 fn default() -> Self {
414 Self::new(vec![])
415 }
416}
417
418fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
424 for plugin in active_plugins {
425 match plugin.name() {
426 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => {
427 tracing::warn!(
428 "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
429 before fallow for accurate analysis"
430 );
431 }
432 "astro" if !root.join(".astro").exists() => {
433 tracing::warn!(
434 "Astro project missing .astro/ types: run `astro sync` \
435 before fallow for accurate analysis"
436 );
437 }
438 _ => {}
439 }
440 }
441}
442
443#[cfg(test)]
444mod tests;