use rustc_hash::FxHashSet;
use std::path::{Path, PathBuf};
use fallow_config::{EntryPointRole, ExternalPluginDef, PackageJson, UsedClassMemberRule};
use super::{PathRule, Plugin, PluginUsedExportRule};
pub(crate) mod builtin;
mod helpers;
use helpers::{
check_has_config_file, discover_config_files, is_external_plugin_active,
prepare_config_pattern, process_config_result, process_external_plugins,
process_static_patterns,
};
fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
matches!(
plugin_name,
"eslint" | "docusaurus" | "jest" | "tanstack-router" | "vitest"
)
}
pub struct PluginRegistry {
plugins: Vec<Box<dyn Plugin>>,
external_plugins: Vec<ExternalPluginDef>,
}
#[derive(Debug, Default)]
pub struct AggregatedPluginResult {
pub entry_patterns: Vec<(PathRule, String)>,
pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
pub config_patterns: Vec<String>,
pub always_used: Vec<(String, String)>,
pub used_exports: Vec<PluginUsedExportRule>,
pub used_class_members: Vec<UsedClassMemberRule>,
pub referenced_dependencies: Vec<String>,
pub discovered_always_used: Vec<(String, String)>,
pub setup_files: Vec<(PathBuf, String)>,
pub tooling_dependencies: Vec<String>,
pub script_used_packages: FxHashSet<String>,
pub virtual_module_prefixes: Vec<String>,
pub virtual_package_suffixes: Vec<String>,
pub generated_import_patterns: Vec<String>,
pub path_aliases: Vec<(String, String)>,
pub active_plugins: Vec<String>,
pub fixture_patterns: Vec<(String, String)>,
pub scss_include_paths: Vec<PathBuf>,
}
impl PluginRegistry {
#[must_use]
pub fn new(external: Vec<ExternalPluginDef>) -> Self {
Self {
plugins: builtin::create_builtin_plugins(),
external_plugins: external,
}
}
#[must_use]
pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
let all_deps = pkg.all_dependency_names();
let mut seen = FxHashSet::default();
let mut dirs = Vec::new();
for plugin in &self.plugins {
if !plugin.is_enabled_with_deps(&all_deps, root) {
continue;
}
for dir in plugin.discovery_hidden_dirs() {
if seen.insert(*dir) {
dirs.push((*dir).to_string());
}
}
}
dirs
}
pub fn run(
&self,
pkg: &PackageJson,
root: &Path,
discovered_files: &[PathBuf],
) -> AggregatedPluginResult {
self.run_with_search_roots(pkg, root, discovered_files, &[root], false)
}
pub fn run_with_search_roots(
&self,
pkg: &PackageJson,
root: &Path,
discovered_files: &[PathBuf],
config_search_roots: &[&Path],
production_mode: bool,
) -> AggregatedPluginResult {
let _span = tracing::info_span!("run_plugins").entered();
let mut result = AggregatedPluginResult::default();
let all_deps = pkg.all_dependency_names();
let active: Vec<&dyn Plugin> = self
.plugins
.iter()
.filter(|p| p.is_enabled_with_deps(&all_deps, root))
.map(AsRef::as_ref)
.collect();
tracing::info!(
plugins = active
.iter()
.map(|p| p.name())
.collect::<Vec<_>>()
.join(", "),
"active plugins"
);
check_meta_framework_prerequisites(&active, root);
self.emit_silent_fail_diagnostics(&active, &all_deps, root, discovered_files);
for plugin in &active {
process_static_patterns(*plugin, root, &mut result);
}
process_external_plugins(
&self.external_plugins,
&all_deps,
root,
discovered_files,
&mut result,
);
let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
.iter()
.filter(|p| !p.config_patterns().is_empty())
.map(|p| {
let matchers: Vec<globset::GlobMatcher> = p
.config_patterns()
.iter()
.filter_map(|pat| {
let prepared = prepare_config_pattern(pat);
globset::Glob::new(&prepared)
.ok()
.map(|g| g.compile_matcher())
})
.collect();
(*p, matchers)
})
.collect();
use rayon::prelude::*;
let needs_relative_files = !config_matchers.is_empty()
|| active.iter().any(|p| p.package_json_config_key().is_some());
let relative_files: Vec<(PathBuf, String)> = if needs_relative_files {
discovered_files
.par_iter()
.map(|f| {
let rel = f
.strip_prefix(root)
.unwrap_or(f)
.to_string_lossy()
.into_owned();
(f.clone(), rel)
})
.collect()
} else {
Vec::new()
};
if !config_matchers.is_empty() {
let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
for (plugin, matchers) in &config_matchers {
let plugin_hits: Vec<&PathBuf> = relative_files
.par_iter()
.filter_map(|(abs_path, rel_path)| {
matchers
.iter()
.any(|m| m.is_match(rel_path.as_str()))
.then_some(abs_path)
})
.collect();
for abs_path in plugin_hits {
if let Ok(source) = std::fs::read_to_string(abs_path) {
let plugin_result = plugin.resolve_config(abs_path, &source, root);
if !plugin_result.is_empty() {
resolved_plugins.insert(plugin.name());
tracing::debug!(
plugin = plugin.name(),
config = %abs_path.display(),
entries = plugin_result.entry_patterns.len(),
deps = plugin_result.referenced_dependencies.len(),
"resolved config"
);
process_config_result(
plugin.name(),
plugin_result,
&mut result,
Some(abs_path),
);
}
}
}
}
let json_configs = discover_config_files(
&config_matchers,
&resolved_plugins,
config_search_roots,
production_mode,
);
for (abs_path, plugin) in &json_configs {
if let Ok(source) = std::fs::read_to_string(abs_path) {
let plugin_result = plugin.resolve_config(abs_path, &source, root);
if !plugin_result.is_empty() {
let rel = abs_path
.strip_prefix(root)
.map(|p| p.to_string_lossy())
.unwrap_or_default();
tracing::debug!(
plugin = plugin.name(),
config = %rel,
entries = plugin_result.entry_patterns.len(),
deps = plugin_result.referenced_dependencies.len(),
"resolved config (filesystem fallback)"
);
process_config_result(
plugin.name(),
plugin_result,
&mut result,
Some(abs_path),
);
}
}
}
}
process_package_json_inline_configs(
&active,
&config_matchers,
&relative_files,
root,
&mut result,
);
result
}
#[expect(
clippy::too_many_arguments,
reason = "Each parameter is a distinct, small value with no natural grouping; \
bundling them into a struct hurts call-site readability."
)]
pub fn run_workspace_fast(
&self,
pkg: &PackageJson,
root: &Path,
project_root: &Path,
precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
relative_files: &[(PathBuf, String)],
skip_config_plugins: &FxHashSet<&str>,
production_mode: bool,
) -> AggregatedPluginResult {
let _span = tracing::info_span!("run_plugins").entered();
let mut result = AggregatedPluginResult::default();
let all_deps = pkg.all_dependency_names();
let active: Vec<&dyn Plugin> = self
.plugins
.iter()
.filter(|p| p.is_enabled_with_deps(&all_deps, root))
.map(AsRef::as_ref)
.collect();
let workspace_files: Vec<PathBuf> = relative_files
.iter()
.map(|(abs_path, _)| abs_path.clone())
.collect();
tracing::info!(
plugins = active
.iter()
.map(|p| p.name())
.collect::<Vec<_>>()
.join(", "),
"active plugins"
);
self.emit_silent_fail_diagnostics(&active, &all_deps, root, &workspace_files);
process_external_plugins(
&self.external_plugins,
&all_deps,
root,
&workspace_files,
&mut result,
);
if active.is_empty() && result.active_plugins.is_empty() {
return result;
}
for plugin in &active {
process_static_patterns(*plugin, root, &mut result);
}
let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
let workspace_matchers: Vec<_> = precompiled_config_matchers
.iter()
.filter(|(p, _)| {
active_names.contains(p.name())
&& (!skip_config_plugins.contains(p.name())
|| must_parse_workspace_config_when_root_active(p.name()))
})
.map(|(plugin, matchers)| (*plugin, matchers.clone()))
.collect();
let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
if !workspace_matchers.is_empty() {
use rayon::prelude::*;
for (plugin, matchers) in &workspace_matchers {
let plugin_hits: Vec<&PathBuf> = relative_files
.par_iter()
.filter_map(|(abs_path, rel_path)| {
matchers
.iter()
.any(|m| m.is_match(rel_path.as_str()))
.then_some(abs_path)
})
.collect();
for abs_path in plugin_hits {
if let Ok(source) = std::fs::read_to_string(abs_path) {
let plugin_result = plugin.resolve_config(abs_path, &source, root);
if !plugin_result.is_empty() {
resolved_ws_plugins.insert(plugin.name());
tracing::debug!(
plugin = plugin.name(),
config = %abs_path.display(),
entries = plugin_result.entry_patterns.len(),
deps = plugin_result.referenced_dependencies.len(),
"resolved config"
);
process_config_result(
plugin.name(),
plugin_result,
&mut result,
Some(abs_path),
);
}
}
}
}
}
let ws_json_configs = if root == project_root {
discover_config_files(
&workspace_matchers,
&resolved_ws_plugins,
&[root],
production_mode,
)
} else {
discover_config_files(
&workspace_matchers,
&resolved_ws_plugins,
&[root, project_root],
production_mode,
)
};
for (abs_path, plugin) in &ws_json_configs {
if let Ok(source) = std::fs::read_to_string(abs_path) {
let plugin_result = plugin.resolve_config(abs_path, &source, root);
if !plugin_result.is_empty() {
let rel = abs_path
.strip_prefix(project_root)
.map(|p| p.to_string_lossy())
.unwrap_or_default();
tracing::debug!(
plugin = plugin.name(),
config = %rel,
entries = plugin_result.entry_patterns.len(),
deps = plugin_result.referenced_dependencies.len(),
"resolved config (workspace filesystem fallback)"
);
process_config_result(
plugin.name(),
plugin_result,
&mut result,
Some(abs_path),
);
}
}
}
result
}
#[must_use]
pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
self.plugins
.iter()
.filter(|p| !p.config_patterns().is_empty())
.map(|p| {
let matchers: Vec<globset::GlobMatcher> = p
.config_patterns()
.iter()
.filter_map(|pat| {
let prepared = prepare_config_pattern(pat);
globset::Glob::new(&prepared)
.ok()
.map(|g| g.compile_matcher())
})
.collect();
(p.as_ref(), matchers)
})
.collect()
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new(vec![])
}
}
impl PluginRegistry {
fn emit_silent_fail_diagnostics(
&self,
active: &[&dyn Plugin],
all_deps: &[String],
root: &Path,
discovered_files: &[PathBuf],
) {
let active_external: Vec<&ExternalPluginDef> = self
.external_plugins
.iter()
.filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
.collect();
let mut diagnostics = detect_pattern_collisions(active, &active_external);
diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
emit_plugin_diagnostics(&diagnostics);
}
}
fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
std::sync::OnceLock::new();
WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
}
fn should_warn(key: String) -> bool {
plugin_warn_dedupe()
.lock()
.map_or(true, |mut set| set.insert(key))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PluginDiagnostic {
PatternCollision {
pattern: String,
owners: Vec<String>,
},
EnablerTypo {
plugin: String,
enabler: String,
suggestion: String,
},
}
pub(crate) fn detect_pattern_collisions(
builtin_active: &[&dyn Plugin],
external_active: &[&ExternalPluginDef],
) -> Vec<PluginDiagnostic> {
use rustc_hash::FxHashMap;
let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
FxHashMap::default();
let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
pattern: String,
name: String| {
let (list, seen) = pattern_owners.entry(pattern).or_default();
if seen.insert(name.clone()) {
list.push(name);
}
};
for plugin in builtin_active {
for pat in plugin.config_patterns() {
record(
&mut pattern_owners,
(*pat).to_string(),
plugin.name().to_string(),
);
}
}
for ext in external_active {
for pat in &ext.config_patterns {
record(&mut pattern_owners, pat.clone(), ext.name.clone());
}
}
let mut findings: Vec<PluginDiagnostic> = pattern_owners
.into_iter()
.filter_map(|(pattern, (owners, _seen))| {
if owners.len() < 2 {
None
} else {
Some(PluginDiagnostic::PatternCollision { pattern, owners })
}
})
.collect();
findings.sort_unstable_by(|a, b| match (a, b) {
(
PluginDiagnostic::PatternCollision { pattern: ap, .. },
PluginDiagnostic::PatternCollision { pattern: bp, .. },
) => ap.cmp(bp),
_ => std::cmp::Ordering::Equal,
});
findings
}
pub(crate) fn detect_enabler_typos(
external_plugins: &[ExternalPluginDef],
all_deps: &[String],
) -> Vec<PluginDiagnostic> {
let mut findings = Vec::new();
for ext in external_plugins {
if ext.detection.is_some() || ext.enablers.is_empty() {
continue;
}
let any_match = ext.enablers.iter().any(|enabler| {
if enabler.ends_with('/') {
all_deps.iter().any(|d| d.starts_with(enabler))
} else {
all_deps.iter().any(|d| d == enabler)
}
});
if any_match {
continue;
}
for enabler in &ext.enablers {
let candidates = all_deps.iter().map(String::as_str);
let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
else {
continue;
};
findings.push(PluginDiagnostic::EnablerTypo {
plugin: ext.name.clone(),
enabler: enabler.clone(),
suggestion: suggestion.to_string(),
});
}
}
findings
}
fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
for finding in findings {
match finding {
PluginDiagnostic::PatternCollision { pattern, owners } => {
let key = format!("collision::{pattern}::{owners:?}");
if !should_warn(key) {
continue;
}
let winner = &owners[0];
let others = owners[1..].join(", ");
tracing::warn!(
"plugin config_patterns collision: identical pattern \
'{pattern}' is claimed by plugins [{joined}]; '{winner}' \
runs first (registration order), others ({others}) \
follow. Rename one of the patterns or remove the \
duplicate plugin to make resolution explicit. A future \
release may reject identical-pattern collisions.",
joined = owners.join(", "),
);
}
PluginDiagnostic::EnablerTypo {
plugin,
enabler,
suggestion,
} => {
let key = format!("enabler::{plugin}::{enabler}");
if !should_warn(key) {
continue;
}
tracing::warn!(
"plugin '{plugin}' enabler '{enabler}' does not match any \
dependency in package.json; did you mean '{suggestion}'? \
The plugin will not activate. A future release may reject \
unmatched enablers.",
);
}
}
}
}
fn process_package_json_inline_configs(
active: &[&dyn Plugin],
config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
relative_files: &[(PathBuf, String)],
root: &Path,
result: &mut AggregatedPluginResult,
) {
for plugin in active {
let Some(key) = plugin.package_json_config_key() else {
continue;
};
if check_has_config_file(*plugin, config_matchers, relative_files) {
continue;
}
let pkg_path = root.join("package.json");
let Ok(content) = std::fs::read_to_string(&pkg_path) else {
continue;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
continue;
};
let Some(config_value) = json.get(key) else {
continue;
};
let config_json = serde_json::to_string(config_value).unwrap_or_default();
let fake_path = root.join(format!("{key}.config.json"));
let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
if plugin_result.is_empty() {
continue;
}
tracing::debug!(
plugin = plugin.name(),
key = key,
"resolved inline package.json config"
);
process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path));
}
}
fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
for plugin in active_plugins {
match plugin.name() {
"nuxt" if !root.join(".nuxt/tsconfig.json").exists() => {
tracing::warn!(
"Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
before fallow for accurate analysis"
);
}
"astro" if !root.join(".astro").exists() => {
tracing::warn!(
"Astro project missing .astro/ types: run `astro sync` \
before fallow for accurate analysis"
);
}
_ => {}
}
}
}
#[cfg(test)]
mod tests;