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