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, UsedClassMemberRule};
11
12use super::{PathRule, Plugin, PluginUsedExportRule};
13
14pub(crate) mod builtin;
15mod helpers;
16
17use helpers::{
18 check_has_config_file, discover_config_files, process_config_result, process_external_plugins,
19 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<UsedClassMemberRule>,
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_config_files(&config_matchers, &resolved_plugins, &[root]);
203 for (abs_path, plugin) in &json_configs {
204 if let Ok(source) = std::fs::read_to_string(abs_path) {
205 let plugin_result = plugin.resolve_config(abs_path, &source, root);
206 if !plugin_result.is_empty() {
207 let rel = abs_path
208 .strip_prefix(root)
209 .map(|p| p.to_string_lossy())
210 .unwrap_or_default();
211 tracing::debug!(
212 plugin = plugin.name(),
213 config = %rel,
214 entries = plugin_result.entry_patterns.len(),
215 deps = plugin_result.referenced_dependencies.len(),
216 "resolved config (filesystem fallback)"
217 );
218 process_config_result(plugin.name(), plugin_result, &mut result);
219 }
220 }
221 }
222 }
223
224 for plugin in &active {
228 if let Some(key) = plugin.package_json_config_key()
229 && !check_has_config_file(*plugin, &config_matchers, &relative_files)
230 {
231 let pkg_path = root.join("package.json");
233 if let Ok(content) = std::fs::read_to_string(&pkg_path)
234 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
235 && let Some(config_value) = json.get(key)
236 {
237 let config_json = serde_json::to_string(config_value).unwrap_or_default();
238 let fake_path = root.join(format!("{key}.config.json"));
239 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
240 if !plugin_result.is_empty() {
241 tracing::debug!(
242 plugin = plugin.name(),
243 key = key,
244 "resolved inline package.json config"
245 );
246 process_config_result(plugin.name(), plugin_result, &mut result);
247 }
248 }
249 }
250 }
251
252 result
253 }
254
255 pub fn run_workspace_fast(
262 &self,
263 pkg: &PackageJson,
264 root: &Path,
265 project_root: &Path,
266 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
267 relative_files: &[(&PathBuf, String)],
268 ) -> AggregatedPluginResult {
269 let _span = tracing::info_span!("run_plugins").entered();
270 let mut result = AggregatedPluginResult::default();
271
272 let all_deps = pkg.all_dependency_names();
274 let active: Vec<&dyn Plugin> = self
275 .plugins
276 .iter()
277 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
278 .map(AsRef::as_ref)
279 .collect();
280
281 tracing::info!(
282 plugins = active
283 .iter()
284 .map(|p| p.name())
285 .collect::<Vec<_>>()
286 .join(", "),
287 "active plugins"
288 );
289
290 if active.is_empty() {
292 return result;
293 }
294
295 for plugin in &active {
297 process_static_patterns(*plugin, root, &mut result);
298 }
299
300 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
303 let workspace_matchers: Vec<_> = precompiled_config_matchers
304 .iter()
305 .filter(|(p, _)| active_names.contains(p.name()))
306 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
307 .collect();
308
309 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
310 if !workspace_matchers.is_empty() {
311 for (plugin, matchers) in &workspace_matchers {
312 for (abs_path, rel_path) in relative_files {
313 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
314 && let Ok(source) = std::fs::read_to_string(abs_path)
315 {
316 resolved_ws_plugins.insert(plugin.name());
319 let plugin_result = plugin.resolve_config(abs_path, &source, root);
320 if !plugin_result.is_empty() {
321 tracing::debug!(
322 plugin = plugin.name(),
323 config = rel_path.as_str(),
324 entries = plugin_result.entry_patterns.len(),
325 deps = plugin_result.referenced_dependencies.len(),
326 "resolved config"
327 );
328 process_config_result(plugin.name(), plugin_result, &mut result);
329 }
330 }
331 }
332 }
333 }
334
335 let ws_json_configs = if root == project_root {
340 discover_config_files(&workspace_matchers, &resolved_ws_plugins, &[root])
341 } else {
342 discover_config_files(
343 &workspace_matchers,
344 &resolved_ws_plugins,
345 &[root, project_root],
346 )
347 };
348 for (abs_path, plugin) in &ws_json_configs {
350 if let Ok(source) = std::fs::read_to_string(abs_path) {
351 let plugin_result = plugin.resolve_config(abs_path, &source, root);
352 if !plugin_result.is_empty() {
353 let rel = abs_path
354 .strip_prefix(project_root)
355 .map(|p| p.to_string_lossy())
356 .unwrap_or_default();
357 tracing::debug!(
358 plugin = plugin.name(),
359 config = %rel,
360 entries = plugin_result.entry_patterns.len(),
361 deps = plugin_result.referenced_dependencies.len(),
362 "resolved config (workspace filesystem fallback)"
363 );
364 process_config_result(plugin.name(), plugin_result, &mut result);
365 }
366 }
367 }
368
369 result
370 }
371
372 #[must_use]
375 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
376 self.plugins
377 .iter()
378 .filter(|p| !p.config_patterns().is_empty())
379 .map(|p| {
380 let matchers: Vec<globset::GlobMatcher> = p
381 .config_patterns()
382 .iter()
383 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
384 .collect();
385 (p.as_ref(), matchers)
386 })
387 .collect()
388 }
389}
390
391impl Default for PluginRegistry {
392 fn default() -> Self {
393 Self::new(vec![])
394 }
395}
396
397fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
403 for plugin in active_plugins {
404 match plugin.name() {
405 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => {
406 tracing::warn!(
407 "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
408 before fallow for accurate analysis"
409 );
410 }
411 "astro" if !root.join(".astro").exists() => {
412 tracing::warn!(
413 "Astro project missing .astro/ types: run `astro sync` \
414 before fallow for accurate analysis"
415 );
416 }
417 _ => {}
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests;