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::Plugin;
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<(String, 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<(String, Vec<String>)>,
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
147 .iter()
148 .any(|p| p.package_json_config_key().is_some());
149 let relative_files: Vec<(&PathBuf, String)> = if needs_relative_files {
150 discovered_files
151 .iter()
152 .map(|f| {
153 let rel = f
154 .strip_prefix(root)
155 .unwrap_or(f)
156 .to_string_lossy()
157 .into_owned();
158 (f, rel)
159 })
160 .collect()
161 } else {
162 Vec::new()
163 };
164
165 if !config_matchers.is_empty() {
166 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
168
169 for (plugin, matchers) in &config_matchers {
170 for (abs_path, rel_path) in &relative_files {
171 if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
172 resolved_plugins.insert(plugin.name());
175 if let Ok(source) = std::fs::read_to_string(abs_path) {
176 let plugin_result = plugin.resolve_config(abs_path, &source, root);
177 if !plugin_result.is_empty() {
178 tracing::debug!(
179 plugin = plugin.name(),
180 config = rel_path.as_str(),
181 entries = plugin_result.entry_patterns.len(),
182 deps = plugin_result.referenced_dependencies.len(),
183 "resolved config"
184 );
185 process_config_result(plugin.name(), plugin_result, &mut result);
186 }
187 }
188 }
189 }
190 }
191
192 let json_configs = discover_json_config_files(
196 &config_matchers,
197 &resolved_plugins,
198 &relative_files,
199 root,
200 );
201 for (abs_path, plugin) in &json_configs {
202 if let Ok(source) = std::fs::read_to_string(abs_path) {
203 let plugin_result = plugin.resolve_config(abs_path, &source, root);
204 if !plugin_result.is_empty() {
205 let rel = abs_path
206 .strip_prefix(root)
207 .map(|p| p.to_string_lossy())
208 .unwrap_or_default();
209 tracing::debug!(
210 plugin = plugin.name(),
211 config = %rel,
212 entries = plugin_result.entry_patterns.len(),
213 deps = plugin_result.referenced_dependencies.len(),
214 "resolved config (filesystem fallback)"
215 );
216 process_config_result(plugin.name(), plugin_result, &mut result);
217 }
218 }
219 }
220 }
221
222 for plugin in &active {
226 if let Some(key) = plugin.package_json_config_key()
227 && !check_has_config_file(*plugin, &config_matchers, &relative_files)
228 {
229 let pkg_path = root.join("package.json");
231 if let Ok(content) = std::fs::read_to_string(&pkg_path)
232 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
233 && let Some(config_value) = json.get(key)
234 {
235 let config_json = serde_json::to_string(config_value).unwrap_or_default();
236 let fake_path = root.join(format!("{key}.config.json"));
237 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
238 if !plugin_result.is_empty() {
239 tracing::debug!(
240 plugin = plugin.name(),
241 key = key,
242 "resolved inline package.json config"
243 );
244 process_config_result(plugin.name(), plugin_result, &mut result);
245 }
246 }
247 }
248 }
249
250 result
251 }
252
253 pub fn run_workspace_fast(
260 &self,
261 pkg: &PackageJson,
262 root: &Path,
263 project_root: &Path,
264 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
265 relative_files: &[(&PathBuf, String)],
266 ) -> AggregatedPluginResult {
267 let _span = tracing::info_span!("run_plugins").entered();
268 let mut result = AggregatedPluginResult::default();
269
270 let all_deps = pkg.all_dependency_names();
272 let active: Vec<&dyn Plugin> = self
273 .plugins
274 .iter()
275 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
276 .map(AsRef::as_ref)
277 .collect();
278
279 tracing::info!(
280 plugins = active
281 .iter()
282 .map(|p| p.name())
283 .collect::<Vec<_>>()
284 .join(", "),
285 "active plugins"
286 );
287
288 if active.is_empty() {
290 return result;
291 }
292
293 for plugin in &active {
295 process_static_patterns(*plugin, root, &mut result);
296 }
297
298 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
301 let workspace_matchers: Vec<_> = precompiled_config_matchers
302 .iter()
303 .filter(|(p, _)| active_names.contains(p.name()))
304 .collect();
305
306 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
307 if !workspace_matchers.is_empty() {
308 for (plugin, matchers) in &workspace_matchers {
309 for (abs_path, rel_path) in relative_files {
310 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
311 && let Ok(source) = std::fs::read_to_string(abs_path)
312 {
313 resolved_ws_plugins.insert(plugin.name());
316 let plugin_result = plugin.resolve_config(abs_path, &source, root);
317 if !plugin_result.is_empty() {
318 tracing::debug!(
319 plugin = plugin.name(),
320 config = rel_path.as_str(),
321 entries = plugin_result.entry_patterns.len(),
322 deps = plugin_result.referenced_dependencies.len(),
323 "resolved config"
324 );
325 process_config_result(plugin.name(), plugin_result, &mut result);
326 }
327 }
328 }
329 }
330 }
331
332 let mut ws_json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
337 let mut ws_seen_paths: FxHashSet<PathBuf> = FxHashSet::default();
338 for plugin in &active {
339 if resolved_ws_plugins.contains(plugin.name()) || plugin.config_patterns().is_empty() {
340 continue;
341 }
342 for pat in plugin.config_patterns() {
343 let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
344 if has_glob {
345 let filename = std::path::Path::new(pat)
348 .file_name()
349 .and_then(|n| n.to_str())
350 .unwrap_or(pat);
351 let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
352 if let Some(matcher) = matcher {
353 let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
354 checked_dirs.insert(root);
355 if root != project_root {
356 checked_dirs.insert(project_root);
357 }
358 for (abs_path, _) in relative_files {
359 if let Some(parent) = abs_path.parent() {
360 checked_dirs.insert(parent);
361 }
362 }
363 for dir in checked_dirs {
364 let candidate = dir.join(filename);
365 if candidate.is_file() && ws_seen_paths.insert(candidate.clone()) {
366 let rel = candidate
367 .strip_prefix(project_root)
368 .map(|p| p.to_string_lossy())
369 .unwrap_or_default();
370 if matcher.is_match(rel.as_ref()) {
371 ws_json_configs.push((candidate, *plugin));
372 }
373 }
374 }
375 }
376 } else {
377 let check_roots: Vec<&Path> = if root == project_root {
379 vec![root]
380 } else {
381 vec![root, project_root]
382 };
383 for check_root in check_roots {
384 let abs_path = check_root.join(pat);
385 if abs_path.is_file() && ws_seen_paths.insert(abs_path.clone()) {
386 ws_json_configs.push((abs_path, *plugin));
387 break; }
389 }
390 }
391 }
392 }
393 for (abs_path, plugin) in &ws_json_configs {
395 if let Ok(source) = std::fs::read_to_string(abs_path) {
396 let plugin_result = plugin.resolve_config(abs_path, &source, root);
397 if !plugin_result.is_empty() {
398 let rel = abs_path
399 .strip_prefix(project_root)
400 .map(|p| p.to_string_lossy())
401 .unwrap_or_default();
402 tracing::debug!(
403 plugin = plugin.name(),
404 config = %rel,
405 entries = plugin_result.entry_patterns.len(),
406 deps = plugin_result.referenced_dependencies.len(),
407 "resolved config (workspace filesystem fallback)"
408 );
409 process_config_result(plugin.name(), plugin_result, &mut result);
410 }
411 }
412 }
413
414 result
415 }
416
417 #[must_use]
420 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
421 self.plugins
422 .iter()
423 .filter(|p| !p.config_patterns().is_empty())
424 .map(|p| {
425 let matchers: Vec<globset::GlobMatcher> = p
426 .config_patterns()
427 .iter()
428 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
429 .collect();
430 (p.as_ref(), matchers)
431 })
432 .collect()
433 }
434}
435
436impl Default for PluginRegistry {
437 fn default() -> Self {
438 Self::new(vec![])
439 }
440}
441
442fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
448 for plugin in active_plugins {
449 match plugin.name() {
450 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => {
451 tracing::warn!(
452 "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
453 before fallow for accurate analysis"
454 );
455 }
456 "astro" if !root.join(".astro").exists() => {
457 tracing::warn!(
458 "Astro project missing .astro/ types: run `astro sync` \
459 before fallow for accurate analysis"
460 );
461 }
462 _ => {}
463 }
464 }
465}
466
467#[cfg(test)]
468mod tests;