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::{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 config_patterns: Vec<String>,
35 pub always_used: Vec<(String, String)>,
37 pub used_exports: Vec<(String, Vec<String>)>,
39 pub referenced_dependencies: Vec<String>,
41 pub discovered_always_used: Vec<(String, String)>,
43 pub setup_files: Vec<(PathBuf, String)>,
45 pub tooling_dependencies: Vec<String>,
47 pub script_used_packages: FxHashSet<String>,
49 pub virtual_module_prefixes: Vec<String>,
52 pub generated_import_patterns: Vec<String>,
55 pub path_aliases: Vec<(String, String)>,
58 pub active_plugins: Vec<String>,
60 pub fixture_patterns: Vec<(String, String)>,
62}
63
64impl PluginRegistry {
65 #[must_use]
67 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
68 Self {
69 plugins: builtin::create_builtin_plugins(),
70 external_plugins: external,
71 }
72 }
73
74 pub fn run(
79 &self,
80 pkg: &PackageJson,
81 root: &Path,
82 discovered_files: &[PathBuf],
83 ) -> AggregatedPluginResult {
84 let _span = tracing::info_span!("run_plugins").entered();
85 let mut result = AggregatedPluginResult::default();
86
87 let all_deps = pkg.all_dependency_names();
90 let active: Vec<&dyn Plugin> = self
91 .plugins
92 .iter()
93 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
94 .map(AsRef::as_ref)
95 .collect();
96
97 tracing::info!(
98 plugins = active
99 .iter()
100 .map(|p| p.name())
101 .collect::<Vec<_>>()
102 .join(", "),
103 "active plugins"
104 );
105
106 for plugin in &active {
108 process_static_patterns(*plugin, root, &mut result);
109 }
110
111 process_external_plugins(
113 &self.external_plugins,
114 &all_deps,
115 root,
116 discovered_files,
117 &mut result,
118 );
119
120 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
123 .iter()
124 .filter(|p| !p.config_patterns().is_empty())
125 .map(|p| {
126 let matchers: Vec<globset::GlobMatcher> = p
127 .config_patterns()
128 .iter()
129 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
130 .collect();
131 (*p, matchers)
132 })
133 .collect();
134
135 let relative_files: Vec<(&PathBuf, String)> = discovered_files
137 .iter()
138 .map(|f| {
139 let rel = f
140 .strip_prefix(root)
141 .unwrap_or(f)
142 .to_string_lossy()
143 .into_owned();
144 (f, rel)
145 })
146 .collect();
147
148 if !config_matchers.is_empty() {
149 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
151
152 for (plugin, matchers) in &config_matchers {
153 for (abs_path, rel_path) in &relative_files {
154 if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
155 resolved_plugins.insert(plugin.name());
158 if let Ok(source) = std::fs::read_to_string(abs_path) {
159 let plugin_result = plugin.resolve_config(abs_path, &source, root);
160 if !plugin_result.is_empty() {
161 tracing::debug!(
162 plugin = plugin.name(),
163 config = rel_path.as_str(),
164 entries = plugin_result.entry_patterns.len(),
165 deps = plugin_result.referenced_dependencies.len(),
166 "resolved config"
167 );
168 process_config_result(plugin.name(), plugin_result, &mut result);
169 }
170 }
171 }
172 }
173 }
174
175 let json_configs = discover_json_config_files(
179 &config_matchers,
180 &resolved_plugins,
181 &relative_files,
182 root,
183 );
184 for (abs_path, plugin) in &json_configs {
185 if let Ok(source) = std::fs::read_to_string(abs_path) {
186 let plugin_result = plugin.resolve_config(abs_path, &source, root);
187 if !plugin_result.is_empty() {
188 let rel = abs_path
189 .strip_prefix(root)
190 .map(|p| p.to_string_lossy())
191 .unwrap_or_default();
192 tracing::debug!(
193 plugin = plugin.name(),
194 config = %rel,
195 entries = plugin_result.entry_patterns.len(),
196 deps = plugin_result.referenced_dependencies.len(),
197 "resolved config (filesystem fallback)"
198 );
199 process_config_result(plugin.name(), plugin_result, &mut result);
200 }
201 }
202 }
203 }
204
205 for plugin in &active {
209 if let Some(key) = plugin.package_json_config_key()
210 && !check_has_config_file(*plugin, &config_matchers, &relative_files)
211 {
212 let pkg_path = root.join("package.json");
214 if let Ok(content) = std::fs::read_to_string(&pkg_path)
215 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
216 && let Some(config_value) = json.get(key)
217 {
218 let config_json = serde_json::to_string(config_value).unwrap_or_default();
219 let fake_path = root.join(format!("{key}.config.json"));
220 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
221 if !plugin_result.is_empty() {
222 tracing::debug!(
223 plugin = plugin.name(),
224 key = key,
225 "resolved inline package.json config"
226 );
227 process_config_result(plugin.name(), plugin_result, &mut result);
228 }
229 }
230 }
231 }
232
233 result
234 }
235
236 pub fn run_workspace_fast(
243 &self,
244 pkg: &PackageJson,
245 root: &Path,
246 project_root: &Path,
247 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
248 relative_files: &[(&PathBuf, String)],
249 ) -> AggregatedPluginResult {
250 let _span = tracing::info_span!("run_plugins").entered();
251 let mut result = AggregatedPluginResult::default();
252
253 let all_deps = pkg.all_dependency_names();
255 let active: Vec<&dyn Plugin> = self
256 .plugins
257 .iter()
258 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
259 .map(AsRef::as_ref)
260 .collect();
261
262 tracing::info!(
263 plugins = active
264 .iter()
265 .map(|p| p.name())
266 .collect::<Vec<_>>()
267 .join(", "),
268 "active plugins"
269 );
270
271 if active.is_empty() {
273 return result;
274 }
275
276 for plugin in &active {
278 process_static_patterns(*plugin, root, &mut result);
279 }
280
281 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
284 let workspace_matchers: Vec<_> = precompiled_config_matchers
285 .iter()
286 .filter(|(p, _)| active_names.contains(p.name()))
287 .collect();
288
289 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
290 if !workspace_matchers.is_empty() {
291 for (plugin, matchers) in &workspace_matchers {
292 for (abs_path, rel_path) in relative_files {
293 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
294 && let Ok(source) = std::fs::read_to_string(abs_path)
295 {
296 resolved_ws_plugins.insert(plugin.name());
299 let plugin_result = plugin.resolve_config(abs_path, &source, root);
300 if !plugin_result.is_empty() {
301 tracing::debug!(
302 plugin = plugin.name(),
303 config = rel_path.as_str(),
304 entries = plugin_result.entry_patterns.len(),
305 deps = plugin_result.referenced_dependencies.len(),
306 "resolved config"
307 );
308 process_config_result(plugin.name(), plugin_result, &mut result);
309 }
310 }
311 }
312 }
313 }
314
315 let mut ws_json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
320 let mut ws_seen_paths: FxHashSet<PathBuf> = FxHashSet::default();
321 for plugin in &active {
322 if resolved_ws_plugins.contains(plugin.name()) || plugin.config_patterns().is_empty() {
323 continue;
324 }
325 for pat in plugin.config_patterns() {
326 let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
327 if has_glob {
328 let filename = std::path::Path::new(pat)
331 .file_name()
332 .and_then(|n| n.to_str())
333 .unwrap_or(pat);
334 let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
335 if let Some(matcher) = matcher {
336 let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
337 checked_dirs.insert(root);
338 if root != project_root {
339 checked_dirs.insert(project_root);
340 }
341 for (abs_path, _) in relative_files {
342 if let Some(parent) = abs_path.parent() {
343 checked_dirs.insert(parent);
344 }
345 }
346 for dir in checked_dirs {
347 let candidate = dir.join(filename);
348 if candidate.is_file() && ws_seen_paths.insert(candidate.clone()) {
349 let rel = candidate
350 .strip_prefix(project_root)
351 .map(|p| p.to_string_lossy())
352 .unwrap_or_default();
353 if matcher.is_match(rel.as_ref()) {
354 ws_json_configs.push((candidate, *plugin));
355 }
356 }
357 }
358 }
359 } else {
360 let check_roots: Vec<&Path> = if root == project_root {
362 vec![root]
363 } else {
364 vec![root, project_root]
365 };
366 for check_root in check_roots {
367 let abs_path = check_root.join(pat);
368 if abs_path.is_file() && ws_seen_paths.insert(abs_path.clone()) {
369 ws_json_configs.push((abs_path, *plugin));
370 break; }
372 }
373 }
374 }
375 }
376 for (abs_path, plugin) in &ws_json_configs {
378 if let Ok(source) = std::fs::read_to_string(abs_path) {
379 let plugin_result = plugin.resolve_config(abs_path, &source, root);
380 if !plugin_result.is_empty() {
381 let rel = abs_path
382 .strip_prefix(project_root)
383 .map(|p| p.to_string_lossy())
384 .unwrap_or_default();
385 tracing::debug!(
386 plugin = plugin.name(),
387 config = %rel,
388 entries = plugin_result.entry_patterns.len(),
389 deps = plugin_result.referenced_dependencies.len(),
390 "resolved config (workspace filesystem fallback)"
391 );
392 process_config_result(plugin.name(), plugin_result, &mut result);
393 }
394 }
395 }
396
397 result
398 }
399
400 #[must_use]
403 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
404 self.plugins
405 .iter()
406 .filter(|p| !p.config_patterns().is_empty())
407 .map(|p| {
408 let matchers: Vec<globset::GlobMatcher> = p
409 .config_patterns()
410 .iter()
411 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
412 .collect();
413 (p.as_ref(), matchers)
414 })
415 .collect()
416 }
417}
418
419impl Default for PluginRegistry {
420 fn default() -> Self {
421 Self::new(vec![])
422 }
423}
424
425#[cfg(test)]
426#[expect(
427 clippy::disallowed_types,
428 reason = "test assertions use std HashMap for readability"
429)]
430mod tests;