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(
279 &self,
280 pkg: &PackageJson,
281 root: &Path,
282 project_root: &Path,
283 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
284 relative_files: &[(PathBuf, String)],
285 skip_config_plugins: &FxHashSet<&str>,
286 ) -> AggregatedPluginResult {
287 let _span = tracing::info_span!("run_plugins").entered();
288 let mut result = AggregatedPluginResult::default();
289
290 let all_deps = pkg.all_dependency_names();
292 let active: Vec<&dyn Plugin> = self
293 .plugins
294 .iter()
295 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
296 .map(AsRef::as_ref)
297 .collect();
298
299 let workspace_files: Vec<PathBuf> = relative_files
300 .iter()
301 .map(|(abs_path, _)| abs_path.clone())
302 .collect();
303
304 tracing::info!(
305 plugins = active
306 .iter()
307 .map(|p| p.name())
308 .collect::<Vec<_>>()
309 .join(", "),
310 "active plugins"
311 );
312
313 process_external_plugins(
314 &self.external_plugins,
315 &all_deps,
316 root,
317 &workspace_files,
318 &mut result,
319 );
320
321 if active.is_empty() && result.active_plugins.is_empty() {
323 return result;
324 }
325
326 for plugin in &active {
328 process_static_patterns(*plugin, root, &mut result);
329 }
330
331 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
334 let workspace_matchers: Vec<_> = precompiled_config_matchers
335 .iter()
336 .filter(|(p, _)| {
337 active_names.contains(p.name())
338 && (!skip_config_plugins.contains(p.name())
339 || must_parse_workspace_config_when_root_active(p.name()))
340 })
341 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
342 .collect();
343
344 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
345 if !workspace_matchers.is_empty() {
346 for (plugin, matchers) in &workspace_matchers {
347 for (abs_path, rel_path) in relative_files {
348 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
349 && let Ok(source) = std::fs::read_to_string(abs_path)
350 {
351 let plugin_result = plugin.resolve_config(abs_path, &source, root);
352 if !plugin_result.is_empty() {
353 resolved_ws_plugins.insert(plugin.name());
354 tracing::debug!(
355 plugin = plugin.name(),
356 config = rel_path.as_str(),
357 entries = plugin_result.entry_patterns.len(),
358 deps = plugin_result.referenced_dependencies.len(),
359 "resolved config"
360 );
361 process_config_result(plugin.name(), plugin_result, &mut result);
362 }
363 }
364 }
365 }
366 }
367
368 let ws_json_configs = if root == project_root {
373 discover_config_files(&workspace_matchers, &resolved_ws_plugins, &[root])
374 } else {
375 discover_config_files(
376 &workspace_matchers,
377 &resolved_ws_plugins,
378 &[root, project_root],
379 )
380 };
381 for (abs_path, plugin) in &ws_json_configs {
383 if let Ok(source) = std::fs::read_to_string(abs_path) {
384 let plugin_result = plugin.resolve_config(abs_path, &source, root);
385 if !plugin_result.is_empty() {
386 let rel = abs_path
387 .strip_prefix(project_root)
388 .map(|p| p.to_string_lossy())
389 .unwrap_or_default();
390 tracing::debug!(
391 plugin = plugin.name(),
392 config = %rel,
393 entries = plugin_result.entry_patterns.len(),
394 deps = plugin_result.referenced_dependencies.len(),
395 "resolved config (workspace filesystem fallback)"
396 );
397 process_config_result(plugin.name(), plugin_result, &mut result);
398 }
399 }
400 }
401
402 result
403 }
404
405 #[must_use]
408 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
409 self.plugins
410 .iter()
411 .filter(|p| !p.config_patterns().is_empty())
412 .map(|p| {
413 let matchers: Vec<globset::GlobMatcher> = p
414 .config_patterns()
415 .iter()
416 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
417 .collect();
418 (p.as_ref(), matchers)
419 })
420 .collect()
421 }
422}
423
424impl Default for PluginRegistry {
425 fn default() -> Self {
426 Self::new(vec![])
427 }
428}
429
430fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
436 for plugin in active_plugins {
437 match plugin.name() {
438 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => {
439 tracing::warn!(
440 "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
441 before fallow for accurate analysis"
442 );
443 }
444 "astro" if !root.join(".astro").exists() => {
445 tracing::warn!(
446 "Astro project missing .astro/ types: run `astro sync` \
447 before fallow for accurate analysis"
448 );
449 }
450 _ => {}
451 }
452 }
453}
454
455#[cfg(test)]
456mod tests;