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};
11
12use super::{PathRule, Plugin, PluginUsedExportRule};
13
14pub(crate) mod builtin;
15mod helpers;
16
17use helpers::{
18 check_has_config_file, discover_json_config_files, process_config_result,
19 process_external_plugins, process_static_patterns,
20};
21
22pub struct PluginRegistry {
24 plugins: Vec<Box<dyn Plugin>>,
25 external_plugins: Vec<ExternalPluginDef>,
26}
27
28#[derive(Debug, Default)]
30pub struct AggregatedPluginResult {
31 pub entry_patterns: Vec<(PathRule, String)>,
33 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
35 pub config_patterns: Vec<String>,
37 pub always_used: Vec<(String, String)>,
39 pub used_exports: Vec<PluginUsedExportRule>,
41 pub referenced_dependencies: Vec<String>,
43 pub discovered_always_used: Vec<(String, String)>,
45 pub setup_files: Vec<(PathBuf, String)>,
47 pub tooling_dependencies: Vec<String>,
49 pub script_used_packages: FxHashSet<String>,
51 pub virtual_module_prefixes: Vec<String>,
54 pub generated_import_patterns: Vec<String>,
57 pub path_aliases: Vec<(String, String)>,
60 pub active_plugins: Vec<String>,
62 pub fixture_patterns: Vec<(String, String)>,
64}
65
66impl PluginRegistry {
67 #[must_use]
69 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
70 Self {
71 plugins: builtin::create_builtin_plugins(),
72 external_plugins: external,
73 }
74 }
75
76 pub fn run(
81 &self,
82 pkg: &PackageJson,
83 root: &Path,
84 discovered_files: &[PathBuf],
85 ) -> AggregatedPluginResult {
86 let _span = tracing::info_span!("run_plugins").entered();
87 let mut result = AggregatedPluginResult::default();
88
89 let all_deps = pkg.all_dependency_names();
92 let active: Vec<&dyn Plugin> = self
93 .plugins
94 .iter()
95 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
96 .map(AsRef::as_ref)
97 .collect();
98
99 tracing::info!(
100 plugins = active
101 .iter()
102 .map(|p| p.name())
103 .collect::<Vec<_>>()
104 .join(", "),
105 "active plugins"
106 );
107
108 check_meta_framework_prerequisites(&active, root);
111
112 for plugin in &active {
114 process_static_patterns(*plugin, root, &mut result);
115 }
116
117 process_external_plugins(
119 &self.external_plugins,
120 &all_deps,
121 root,
122 discovered_files,
123 &mut result,
124 );
125
126 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
129 .iter()
130 .filter(|p| !p.config_patterns().is_empty())
131 .map(|p| {
132 let matchers: Vec<globset::GlobMatcher> = p
133 .config_patterns()
134 .iter()
135 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
136 .collect();
137 (*p, matchers)
138 })
139 .collect();
140
141 let needs_relative_files = !config_matchers.is_empty()
146 || active.iter().any(|p| p.package_json_config_key().is_some());
147 let relative_files: Vec<(&PathBuf, String)> = if needs_relative_files {
148 discovered_files
149 .iter()
150 .map(|f| {
151 let rel = f
152 .strip_prefix(root)
153 .unwrap_or(f)
154 .to_string_lossy()
155 .into_owned();
156 (f, rel)
157 })
158 .collect()
159 } else {
160 Vec::new()
161 };
162
163 if !config_matchers.is_empty() {
164 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
166
167 for (plugin, matchers) in &config_matchers {
168 for (abs_path, rel_path) in &relative_files {
169 if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
170 resolved_plugins.insert(plugin.name());
173 if let Ok(source) = std::fs::read_to_string(abs_path) {
174 let plugin_result = plugin.resolve_config(abs_path, &source, root);
175 if !plugin_result.is_empty() {
176 tracing::debug!(
177 plugin = plugin.name(),
178 config = rel_path.as_str(),
179 entries = plugin_result.entry_patterns.len(),
180 deps = plugin_result.referenced_dependencies.len(),
181 "resolved config"
182 );
183 process_config_result(plugin.name(), plugin_result, &mut result);
184 }
185 }
186 }
187 }
188 }
189
190 let json_configs = discover_json_config_files(
194 &config_matchers,
195 &resolved_plugins,
196 &relative_files,
197 root,
198 );
199 for (abs_path, plugin) in &json_configs {
200 if let Ok(source) = std::fs::read_to_string(abs_path) {
201 let plugin_result = plugin.resolve_config(abs_path, &source, root);
202 if !plugin_result.is_empty() {
203 let rel = abs_path
204 .strip_prefix(root)
205 .map(|p| p.to_string_lossy())
206 .unwrap_or_default();
207 tracing::debug!(
208 plugin = plugin.name(),
209 config = %rel,
210 entries = plugin_result.entry_patterns.len(),
211 deps = plugin_result.referenced_dependencies.len(),
212 "resolved config (filesystem fallback)"
213 );
214 process_config_result(plugin.name(), plugin_result, &mut result);
215 }
216 }
217 }
218 }
219
220 for plugin in &active {
224 if let Some(key) = plugin.package_json_config_key()
225 && !check_has_config_file(*plugin, &config_matchers, &relative_files)
226 {
227 let pkg_path = root.join("package.json");
229 if let Ok(content) = std::fs::read_to_string(&pkg_path)
230 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
231 && let Some(config_value) = json.get(key)
232 {
233 let config_json = serde_json::to_string(config_value).unwrap_or_default();
234 let fake_path = root.join(format!("{key}.config.json"));
235 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
236 if !plugin_result.is_empty() {
237 tracing::debug!(
238 plugin = plugin.name(),
239 key = key,
240 "resolved inline package.json config"
241 );
242 process_config_result(plugin.name(), plugin_result, &mut result);
243 }
244 }
245 }
246 }
247
248 result
249 }
250
251 pub fn run_workspace_fast(
258 &self,
259 pkg: &PackageJson,
260 root: &Path,
261 project_root: &Path,
262 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
263 relative_files: &[(&PathBuf, String)],
264 ) -> AggregatedPluginResult {
265 let _span = tracing::info_span!("run_plugins").entered();
266 let mut result = AggregatedPluginResult::default();
267
268 let all_deps = pkg.all_dependency_names();
270 let active: Vec<&dyn Plugin> = self
271 .plugins
272 .iter()
273 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
274 .map(AsRef::as_ref)
275 .collect();
276
277 tracing::info!(
278 plugins = active
279 .iter()
280 .map(|p| p.name())
281 .collect::<Vec<_>>()
282 .join(", "),
283 "active plugins"
284 );
285
286 if active.is_empty() {
288 return result;
289 }
290
291 for plugin in &active {
293 process_static_patterns(*plugin, root, &mut result);
294 }
295
296 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
299 let workspace_matchers: Vec<_> = precompiled_config_matchers
300 .iter()
301 .filter(|(p, _)| active_names.contains(p.name()))
302 .collect();
303
304 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
305 if !workspace_matchers.is_empty() {
306 for (plugin, matchers) in &workspace_matchers {
307 for (abs_path, rel_path) in relative_files {
308 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
309 && let Ok(source) = std::fs::read_to_string(abs_path)
310 {
311 resolved_ws_plugins.insert(plugin.name());
314 let plugin_result = plugin.resolve_config(abs_path, &source, root);
315 if !plugin_result.is_empty() {
316 tracing::debug!(
317 plugin = plugin.name(),
318 config = rel_path.as_str(),
319 entries = plugin_result.entry_patterns.len(),
320 deps = plugin_result.referenced_dependencies.len(),
321 "resolved config"
322 );
323 process_config_result(plugin.name(), plugin_result, &mut result);
324 }
325 }
326 }
327 }
328 }
329
330 let mut ws_json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
335 let mut ws_seen_paths: FxHashSet<PathBuf> = FxHashSet::default();
336 for plugin in &active {
337 if resolved_ws_plugins.contains(plugin.name()) || plugin.config_patterns().is_empty() {
338 continue;
339 }
340 for pat in plugin.config_patterns() {
341 let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
342 if has_glob {
343 let filename = std::path::Path::new(pat)
346 .file_name()
347 .and_then(|n| n.to_str())
348 .unwrap_or(pat);
349 let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
350 if let Some(matcher) = matcher {
351 let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
352 checked_dirs.insert(root);
353 if root != project_root {
354 checked_dirs.insert(project_root);
355 }
356 for (abs_path, _) in relative_files {
357 if let Some(parent) = abs_path.parent() {
358 checked_dirs.insert(parent);
359 }
360 }
361 for dir in checked_dirs {
362 let candidate = dir.join(filename);
363 if candidate.is_file() && ws_seen_paths.insert(candidate.clone()) {
364 let rel = candidate
365 .strip_prefix(project_root)
366 .map(|p| p.to_string_lossy())
367 .unwrap_or_default();
368 if matcher.is_match(rel.as_ref()) {
369 ws_json_configs.push((candidate, *plugin));
370 }
371 }
372 }
373 }
374 } else {
375 let check_roots: Vec<&Path> = if root == project_root {
377 vec![root]
378 } else {
379 vec![root, project_root]
380 };
381 for check_root in check_roots {
382 let abs_path = check_root.join(pat);
383 if abs_path.is_file() && ws_seen_paths.insert(abs_path.clone()) {
384 ws_json_configs.push((abs_path, *plugin));
385 break; }
387 }
388 }
389 }
390 }
391 for (abs_path, plugin) in &ws_json_configs {
393 if let Ok(source) = std::fs::read_to_string(abs_path) {
394 let plugin_result = plugin.resolve_config(abs_path, &source, root);
395 if !plugin_result.is_empty() {
396 let rel = abs_path
397 .strip_prefix(project_root)
398 .map(|p| p.to_string_lossy())
399 .unwrap_or_default();
400 tracing::debug!(
401 plugin = plugin.name(),
402 config = %rel,
403 entries = plugin_result.entry_patterns.len(),
404 deps = plugin_result.referenced_dependencies.len(),
405 "resolved config (workspace filesystem fallback)"
406 );
407 process_config_result(plugin.name(), plugin_result, &mut result);
408 }
409 }
410 }
411
412 result
413 }
414
415 #[must_use]
418 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
419 self.plugins
420 .iter()
421 .filter(|p| !p.config_patterns().is_empty())
422 .map(|p| {
423 let matchers: Vec<globset::GlobMatcher> = p
424 .config_patterns()
425 .iter()
426 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
427 .collect();
428 (p.as_ref(), matchers)
429 })
430 .collect()
431 }
432}
433
434impl Default for PluginRegistry {
435 fn default() -> Self {
436 Self::new(vec![])
437 }
438}
439
440fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
446 for plugin in active_plugins {
447 match plugin.name() {
448 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => {
449 tracing::warn!(
450 "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
451 before fallow for accurate analysis"
452 );
453 }
454 "astro" if !root.join(".astro").exists() => {
455 tracing::warn!(
456 "Astro project missing .astro/ types: run `astro sync` \
457 before fallow for accurate analysis"
458 );
459 }
460 _ => {}
461 }
462 }
463}
464
465#[cfg(test)]
466mod tests;