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 used_class_members: Vec<String>,
45 pub referenced_dependencies: Vec<String>,
47 pub discovered_always_used: Vec<(String, String)>,
49 pub setup_files: Vec<(PathBuf, String)>,
51 pub tooling_dependencies: Vec<String>,
53 pub script_used_packages: FxHashSet<String>,
55 pub virtual_module_prefixes: Vec<String>,
58 pub generated_import_patterns: Vec<String>,
61 pub path_aliases: Vec<(String, String)>,
64 pub active_plugins: Vec<String>,
66 pub fixture_patterns: Vec<(String, String)>,
68 pub scss_include_paths: Vec<PathBuf>,
73}
74
75impl PluginRegistry {
76 #[must_use]
78 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
79 Self {
80 plugins: builtin::create_builtin_plugins(),
81 external_plugins: external,
82 }
83 }
84
85 pub fn run(
90 &self,
91 pkg: &PackageJson,
92 root: &Path,
93 discovered_files: &[PathBuf],
94 ) -> AggregatedPluginResult {
95 let _span = tracing::info_span!("run_plugins").entered();
96 let mut result = AggregatedPluginResult::default();
97
98 let all_deps = pkg.all_dependency_names();
101 let active: Vec<&dyn Plugin> = self
102 .plugins
103 .iter()
104 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
105 .map(AsRef::as_ref)
106 .collect();
107
108 tracing::info!(
109 plugins = active
110 .iter()
111 .map(|p| p.name())
112 .collect::<Vec<_>>()
113 .join(", "),
114 "active plugins"
115 );
116
117 check_meta_framework_prerequisites(&active, root);
120
121 for plugin in &active {
123 process_static_patterns(*plugin, root, &mut result);
124 }
125
126 process_external_plugins(
128 &self.external_plugins,
129 &all_deps,
130 root,
131 discovered_files,
132 &mut result,
133 );
134
135 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
138 .iter()
139 .filter(|p| !p.config_patterns().is_empty())
140 .map(|p| {
141 let matchers: Vec<globset::GlobMatcher> = p
142 .config_patterns()
143 .iter()
144 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
145 .collect();
146 (*p, matchers)
147 })
148 .collect();
149
150 let needs_relative_files = !config_matchers.is_empty()
155 || active.iter().any(|p| p.package_json_config_key().is_some());
156 let relative_files: Vec<(&PathBuf, String)> = if needs_relative_files {
157 discovered_files
158 .iter()
159 .map(|f| {
160 let rel = f
161 .strip_prefix(root)
162 .unwrap_or(f)
163 .to_string_lossy()
164 .into_owned();
165 (f, rel)
166 })
167 .collect()
168 } else {
169 Vec::new()
170 };
171
172 if !config_matchers.is_empty() {
173 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
175
176 for (plugin, matchers) in &config_matchers {
177 for (abs_path, rel_path) in &relative_files {
178 if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
179 resolved_plugins.insert(plugin.name());
182 if let Ok(source) = std::fs::read_to_string(abs_path) {
183 let plugin_result = plugin.resolve_config(abs_path, &source, root);
184 if !plugin_result.is_empty() {
185 tracing::debug!(
186 plugin = plugin.name(),
187 config = rel_path.as_str(),
188 entries = plugin_result.entry_patterns.len(),
189 deps = plugin_result.referenced_dependencies.len(),
190 "resolved config"
191 );
192 process_config_result(plugin.name(), plugin_result, &mut result);
193 }
194 }
195 }
196 }
197 }
198
199 let json_configs = discover_json_config_files(
203 &config_matchers,
204 &resolved_plugins,
205 &relative_files,
206 root,
207 );
208 for (abs_path, plugin) in &json_configs {
209 if let Ok(source) = std::fs::read_to_string(abs_path) {
210 let plugin_result = plugin.resolve_config(abs_path, &source, root);
211 if !plugin_result.is_empty() {
212 let rel = abs_path
213 .strip_prefix(root)
214 .map(|p| p.to_string_lossy())
215 .unwrap_or_default();
216 tracing::debug!(
217 plugin = plugin.name(),
218 config = %rel,
219 entries = plugin_result.entry_patterns.len(),
220 deps = plugin_result.referenced_dependencies.len(),
221 "resolved config (filesystem fallback)"
222 );
223 process_config_result(plugin.name(), plugin_result, &mut result);
224 }
225 }
226 }
227 }
228
229 for plugin in &active {
233 if let Some(key) = plugin.package_json_config_key()
234 && !check_has_config_file(*plugin, &config_matchers, &relative_files)
235 {
236 let pkg_path = root.join("package.json");
238 if let Ok(content) = std::fs::read_to_string(&pkg_path)
239 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
240 && let Some(config_value) = json.get(key)
241 {
242 let config_json = serde_json::to_string(config_value).unwrap_or_default();
243 let fake_path = root.join(format!("{key}.config.json"));
244 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
245 if !plugin_result.is_empty() {
246 tracing::debug!(
247 plugin = plugin.name(),
248 key = key,
249 "resolved inline package.json config"
250 );
251 process_config_result(plugin.name(), plugin_result, &mut result);
252 }
253 }
254 }
255 }
256
257 result
258 }
259
260 pub fn run_workspace_fast(
267 &self,
268 pkg: &PackageJson,
269 root: &Path,
270 project_root: &Path,
271 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
272 relative_files: &[(&PathBuf, String)],
273 ) -> AggregatedPluginResult {
274 let _span = tracing::info_span!("run_plugins").entered();
275 let mut result = AggregatedPluginResult::default();
276
277 let all_deps = pkg.all_dependency_names();
279 let active: Vec<&dyn Plugin> = self
280 .plugins
281 .iter()
282 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
283 .map(AsRef::as_ref)
284 .collect();
285
286 tracing::info!(
287 plugins = active
288 .iter()
289 .map(|p| p.name())
290 .collect::<Vec<_>>()
291 .join(", "),
292 "active plugins"
293 );
294
295 if active.is_empty() {
297 return result;
298 }
299
300 for plugin in &active {
302 process_static_patterns(*plugin, root, &mut result);
303 }
304
305 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
308 let workspace_matchers: Vec<_> = precompiled_config_matchers
309 .iter()
310 .filter(|(p, _)| active_names.contains(p.name()))
311 .collect();
312
313 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
314 if !workspace_matchers.is_empty() {
315 for (plugin, matchers) in &workspace_matchers {
316 for (abs_path, rel_path) in relative_files {
317 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
318 && let Ok(source) = std::fs::read_to_string(abs_path)
319 {
320 resolved_ws_plugins.insert(plugin.name());
323 let plugin_result = plugin.resolve_config(abs_path, &source, root);
324 if !plugin_result.is_empty() {
325 tracing::debug!(
326 plugin = plugin.name(),
327 config = rel_path.as_str(),
328 entries = plugin_result.entry_patterns.len(),
329 deps = plugin_result.referenced_dependencies.len(),
330 "resolved config"
331 );
332 process_config_result(plugin.name(), plugin_result, &mut result);
333 }
334 }
335 }
336 }
337 }
338
339 let mut ws_json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
344 let mut ws_seen_paths: FxHashSet<PathBuf> = FxHashSet::default();
345 for plugin in &active {
346 if resolved_ws_plugins.contains(plugin.name()) || plugin.config_patterns().is_empty() {
347 continue;
348 }
349 for pat in plugin.config_patterns() {
350 let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
351 if has_glob {
352 let filename = std::path::Path::new(pat)
355 .file_name()
356 .and_then(|n| n.to_str())
357 .unwrap_or(pat);
358 let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
359 if let Some(matcher) = matcher {
360 let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
361 checked_dirs.insert(root);
362 if root != project_root {
363 checked_dirs.insert(project_root);
364 }
365 for (abs_path, _) in relative_files {
366 if let Some(parent) = abs_path.parent() {
367 checked_dirs.insert(parent);
368 }
369 }
370 for dir in checked_dirs {
371 let candidate = dir.join(filename);
372 if candidate.is_file() && ws_seen_paths.insert(candidate.clone()) {
373 let rel = candidate
374 .strip_prefix(project_root)
375 .map(|p| p.to_string_lossy())
376 .unwrap_or_default();
377 if matcher.is_match(rel.as_ref()) {
378 ws_json_configs.push((candidate, *plugin));
379 }
380 }
381 }
382 }
383 } else {
384 let check_roots: Vec<&Path> = if root == project_root {
386 vec![root]
387 } else {
388 vec![root, project_root]
389 };
390 for check_root in check_roots {
391 let abs_path = check_root.join(pat);
392 if abs_path.is_file() && ws_seen_paths.insert(abs_path.clone()) {
393 ws_json_configs.push((abs_path, *plugin));
394 break; }
396 }
397 }
398 }
399 }
400 for (abs_path, plugin) in &ws_json_configs {
402 if let Ok(source) = std::fs::read_to_string(abs_path) {
403 let plugin_result = plugin.resolve_config(abs_path, &source, root);
404 if !plugin_result.is_empty() {
405 let rel = abs_path
406 .strip_prefix(project_root)
407 .map(|p| p.to_string_lossy())
408 .unwrap_or_default();
409 tracing::debug!(
410 plugin = plugin.name(),
411 config = %rel,
412 entries = plugin_result.entry_patterns.len(),
413 deps = plugin_result.referenced_dependencies.len(),
414 "resolved config (workspace filesystem fallback)"
415 );
416 process_config_result(plugin.name(), plugin_result, &mut result);
417 }
418 }
419 }
420
421 result
422 }
423
424 #[must_use]
427 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
428 self.plugins
429 .iter()
430 .filter(|p| !p.config_patterns().is_empty())
431 .map(|p| {
432 let matchers: Vec<globset::GlobMatcher> = p
433 .config_patterns()
434 .iter()
435 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
436 .collect();
437 (p.as_ref(), matchers)
438 })
439 .collect()
440 }
441}
442
443impl Default for PluginRegistry {
444 fn default() -> Self {
445 Self::new(vec![])
446 }
447}
448
449fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
455 for plugin in active_plugins {
456 match plugin.name() {
457 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => {
458 tracing::warn!(
459 "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
460 before fallow for accurate analysis"
461 );
462 }
463 "astro" if !root.join(".astro").exists() => {
464 tracing::warn!(
465 "Astro project missing .astro/ types: run `astro sync` \
466 before fallow for accurate analysis"
467 );
468 }
469 _ => {}
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests;