use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
use crate::config::UsedClassMemberRule;
pub const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
#[serde(rename_all = "camelCase")]
pub enum EntryPointRole {
Runtime,
Test,
#[default]
Support,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoImportKind {
Named,
Default,
DefaultComponent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutoImportRule {
pub name: String,
pub source: PathBuf,
pub kind: AutoImportKind,
pub scope: Vec<PathBuf>,
}
impl AutoImportRule {
#[must_use]
pub const fn new(name: String, source: PathBuf, kind: AutoImportKind) -> Self {
Self {
name,
source,
kind,
scope: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JsxImportSourceRule {
pub source: String,
pub config_dir: PathBuf,
pub include: Vec<String>,
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PluginDetection {
Dependency {
package: String,
},
FileExists {
pattern: String,
},
All {
conditions: Vec<Self>,
},
Any {
conditions: Vec<Self>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ExternalPluginDef {
#[serde(rename = "$schema", default, skip_serializing)]
#[schemars(skip)]
pub schema: Option<String>,
pub name: String,
#[serde(default)]
pub detection: Option<PluginDetection>,
#[serde(default)]
pub enablers: Vec<String>,
#[serde(default)]
pub entry_points: Vec<String>,
#[serde(default = "default_external_entry_point_role")]
pub entry_point_role: EntryPointRole,
#[serde(default)]
pub manifest_entries: Vec<ManifestEntryRule>,
#[serde(default)]
pub config_patterns: Vec<String>,
#[serde(default)]
pub config_default_export_opt_out: Vec<String>,
#[serde(default)]
pub always_used: Vec<String>,
#[serde(default)]
pub tooling_dependencies: Vec<String>,
#[serde(default)]
pub used_exports: Vec<ExternalUsedExport>,
#[serde(default)]
pub used_class_members: Vec<UsedClassMemberRule>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ExternalUsedExport {
pub pattern: String,
pub exports: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
#[serde(rename_all = "lowercase")]
pub enum ManifestFormat {
#[default]
Jsonc,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ManifestFieldPath {
raw: String,
segments: Vec<ManifestFieldSegment>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ManifestFieldSegment {
Key(String),
Each,
}
impl ManifestFieldPath {
#[must_use]
pub fn as_str(&self) -> &str {
&self.raw
}
#[must_use]
pub fn segments(&self) -> &[ManifestFieldSegment] {
&self.segments
}
fn parse(raw: String) -> Result<Self, String> {
if raw.is_empty() {
return Err("manifest field path must not be empty".to_string());
}
let mut segments = Vec::new();
for component in raw.split('.') {
if component.is_empty() {
return Err(format!(
"manifest field path '{raw}' contains an empty segment"
));
}
let key_end = component.find('[').unwrap_or(component.len());
let key = &component[..key_end];
if key.is_empty() || key.contains(['{', '}', ']']) {
return Err(format!(
"manifest field path '{raw}' contains unsupported bracket syntax"
));
}
segments.push(ManifestFieldSegment::Key(key.to_string()));
let mut suffix = &component[key_end..];
while !suffix.is_empty() {
let Some(rest) = suffix.strip_prefix("[*]") else {
return Err(format!(
"manifest field path '{raw}' only supports exact '[*]' array traversal"
));
};
segments.push(ManifestFieldSegment::Each);
suffix = rest;
}
}
Ok(Self { raw, segments })
}
}
impl FromStr for ManifestFieldPath {
type Err = String;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
Self::parse(raw.to_string())
}
}
impl fmt::Display for ManifestFieldPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.raw)
}
}
impl Serialize for ManifestFieldPath {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.raw)
}
}
impl<'de> Deserialize<'de> for ManifestFieldPath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Self::parse(raw).map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestPathPart {
Literal(String),
Field(ManifestFieldPath),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestPathTemplate {
raw: String,
parts: Vec<ManifestPathPart>,
}
impl ManifestPathTemplate {
#[must_use]
pub fn as_str(&self) -> &str {
&self.raw
}
#[must_use]
pub fn parts(&self) -> &[ManifestPathPart] {
&self.parts
}
#[must_use]
pub fn validation_pattern(&self) -> String {
let mut pattern = String::with_capacity(self.raw.len());
for part in &self.parts {
match part {
ManifestPathPart::Literal(literal) => pattern.push_str(literal),
ManifestPathPart::Field(_) => pattern.push_str("fallowinterp"),
}
}
pattern
}
fn parse(raw: String) -> Result<Self, String> {
let mut parts = Vec::new();
let mut rest = raw.as_str();
while let Some(start) = rest.find("${") {
if start > 0 {
parts.push(ManifestPathPart::Literal(rest[..start].to_string()));
}
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
return Err(format!(
"manifest entry path '{raw}' contains an unterminated interpolation"
));
};
let field = after[..end].parse::<ManifestFieldPath>()?;
parts.push(ManifestPathPart::Field(field));
rest = &after[end + 1..];
}
if !rest.is_empty() {
parts.push(ManifestPathPart::Literal(rest.to_string()));
}
if parts.is_empty() {
parts.push(ManifestPathPart::Literal(String::new()));
}
Ok(Self { raw, parts })
}
}
impl FromStr for ManifestPathTemplate {
type Err = String;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
Self::parse(raw.to_string())
}
}
impl fmt::Display for ManifestPathTemplate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.raw)
}
}
impl Serialize for ManifestPathTemplate {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.raw)
}
}
impl<'de> Deserialize<'de> for ManifestPathTemplate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Self::parse(raw).map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum ManifestCondition {
Exists(ManifestExistsPredicate),
Equals(serde_json::Value),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ManifestExistsPredicate {
pub exists: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ManifestEntryRule {
pub manifests: String,
#[serde(default)]
pub format: ManifestFormat,
#[serde(default)]
#[schemars(with = "BTreeMap<String, ManifestCondition>")]
pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
pub entries: Vec<ManifestSeedRule>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ManifestSeedRule {
#[schemars(with = "String")]
pub path: ManifestPathTemplate,
#[serde(default)]
#[schemars(with = "BTreeMap<String, ManifestCondition>")]
pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
}
fn default_external_entry_point_role() -> EntryPointRole {
EntryPointRole::Support
}
impl ExternalPluginDef {
#[must_use]
pub fn json_schema() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
}
pub fn validate_user_globs(
&self,
) -> Result<(), Vec<crate::config::glob_validation::GlobValidationError>> {
use crate::config::glob_validation::{compile_user_glob, validate_user_globs};
let mut errors = Vec::new();
validate_user_globs(&self.entry_points, "framework[].entryPoints", &mut errors);
validate_user_globs(&self.always_used, "framework[].alwaysUsed", &mut errors);
validate_user_globs(
&self.config_patterns,
"framework[].configPatterns",
&mut errors,
);
for used in &self.used_exports {
if let Err(e) = compile_user_glob(&used.pattern, "framework[].usedExports[].pattern") {
errors.push(e);
}
}
for rule in &self.manifest_entries {
if let Err(e) =
compile_user_glob(&rule.manifests, "framework[].manifestEntries[].manifests")
{
errors.push(e);
}
for seed in &rule.entries {
let probe = seed.path.validation_pattern();
if let Err(e) =
compile_user_glob(&probe, "framework[].manifestEntries[].entries[].path")
{
errors.push(e);
}
}
}
if let Some(detection) = &self.detection {
validate_detection_user_globs(detection, "framework[].detection", &mut errors);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
fn validate_detection_user_globs(
detection: &PluginDetection,
field: &'static str,
errors: &mut Vec<crate::config::glob_validation::GlobValidationError>,
) {
match detection {
PluginDetection::Dependency { .. } => {}
PluginDetection::FileExists { pattern } => {
if let Err(e) = crate::config::glob_validation::compile_user_glob(pattern, field) {
errors.push(e);
}
}
PluginDetection::All { conditions } | PluginDetection::Any { conditions } => {
for condition in conditions {
validate_detection_user_globs(condition, field, errors);
}
}
}
}
pub fn discover_and_validate_external_plugins(
root: &Path,
config_plugin_paths: &[String],
) -> Result<Vec<ExternalPluginDef>, Vec<crate::config::glob_validation::GlobValidationError>> {
let plugins = discover_external_plugins(root, config_plugin_paths);
let mut errors = Vec::new();
for plugin in &plugins {
if let Err(mut plugin_errors) = plugin.validate_user_globs() {
errors.append(&mut plugin_errors);
}
}
if errors.is_empty() {
Ok(plugins)
} else {
Err(errors)
}
}
enum PluginFormat {
Toml,
Json,
Jsonc,
}
impl PluginFormat {
fn from_path(path: &Path) -> Option<Self> {
match path.extension().and_then(|e| e.to_str()) {
Some("toml") => Some(Self::Toml),
Some("json") => Some(Self::Json),
Some("jsonc") => Some(Self::Jsonc),
_ => None,
}
}
}
const DEFAULT_PLUGINS_DIR_NAME: &str = "plugins";
pub const ROOT_PLUGIN_FILE_PREFIX: &str = "fallow-plugin-";
fn is_plugin_file(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
}
fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
match format {
PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
Ok(plugin) => Some(plugin),
Err(e) => {
tracing::warn!("failed to parse external plugin {}: {e}", path.display());
None
}
},
PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
Ok(plugin) => Some(plugin),
Err(e) => {
tracing::warn!("failed to parse external plugin {}: {e}", path.display());
None
}
},
PluginFormat::Jsonc => match crate::jsonc::parse_to_value::<ExternalPluginDef>(content) {
Ok(plugin) => Some(plugin),
Err(e) => {
tracing::warn!("failed to parse external plugin {}: {e}", path.display());
None
}
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConfiguredPluginDiagnosticKind {
Missing,
OutsideProjectRoot,
UnsupportedFormat,
Unreadable,
InvalidDefinition,
NoPluginFiles,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ConfiguredPluginDiagnostic {
pub kind: ConfiguredPluginDiagnosticKind,
pub path: PathBuf,
}
#[must_use]
pub fn diagnose_configured_external_plugins(
root: &Path,
config_plugin_paths: &[String],
) -> Vec<ConfiguredPluginDiagnostic> {
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut diagnostics = Vec::new();
for path_str in config_plugin_paths {
let path = root.join(path_str);
let metadata = match std::fs::metadata(&path) {
Ok(metadata) => metadata,
Err(error) => {
diagnostics.push(ConfiguredPluginDiagnostic {
kind: if error.kind() == std::io::ErrorKind::NotFound {
ConfiguredPluginDiagnosticKind::Missing
} else {
ConfiguredPluginDiagnosticKind::Unreadable
},
path,
});
continue;
}
};
if !is_within_root(&path, &canonical_root) {
diagnostics.push(ConfiguredPluginDiagnostic {
kind: ConfiguredPluginDiagnosticKind::OutsideProjectRoot,
path,
});
continue;
}
if metadata.is_dir() {
diagnose_configured_plugin_dir(&path, &canonical_root, &mut diagnostics);
} else if metadata.is_file() {
diagnose_configured_plugin_file(&path, &canonical_root, &mut diagnostics);
} else {
diagnostics.push(ConfiguredPluginDiagnostic {
kind: ConfiguredPluginDiagnosticKind::UnsupportedFormat,
path,
});
}
}
diagnostics
}
fn diagnose_configured_plugin_dir(
dir: &Path,
canonical_root: &Path,
diagnostics: &mut Vec<ConfiguredPluginDiagnostic>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
diagnostics.push(ConfiguredPluginDiagnostic {
kind: ConfiguredPluginDiagnosticKind::Unreadable,
path: dir.to_path_buf(),
});
return;
};
let mut plugin_files = Vec::new();
let mut entry_failed = false;
for entry in entries {
match entry {
Ok(entry) => {
let path = entry.path();
if path.is_file() && is_plugin_file(&path) {
plugin_files.push(path);
}
}
Err(_) => {
entry_failed = true;
diagnostics.push(ConfiguredPluginDiagnostic {
kind: ConfiguredPluginDiagnosticKind::Unreadable,
path: dir.to_path_buf(),
});
}
}
}
if plugin_files.is_empty() && !entry_failed {
diagnostics.push(ConfiguredPluginDiagnostic {
kind: ConfiguredPluginDiagnosticKind::NoPluginFiles,
path: dir.to_path_buf(),
});
return;
}
plugin_files.sort();
for path in plugin_files {
diagnose_configured_plugin_file(&path, canonical_root, diagnostics);
}
}
fn diagnose_configured_plugin_file(
path: &Path,
canonical_root: &Path,
diagnostics: &mut Vec<ConfiguredPluginDiagnostic>,
) {
let kind = if !is_within_root(path, canonical_root) {
Some(ConfiguredPluginDiagnosticKind::OutsideProjectRoot)
} else if let Some(format) = PluginFormat::from_path(path) {
match std::fs::read_to_string(path) {
Ok(content) => match parse_plugin(&content, &format, path) {
Some(plugin) if !plugin.name.is_empty() => None,
Some(_) | None => Some(ConfiguredPluginDiagnosticKind::InvalidDefinition),
},
Err(_) => Some(ConfiguredPluginDiagnosticKind::Unreadable),
}
} else {
Some(ConfiguredPluginDiagnosticKind::UnsupportedFormat)
};
if let Some(kind) = kind {
diagnostics.push(ConfiguredPluginDiagnostic {
kind,
path: path.to_path_buf(),
});
}
}
pub fn discover_external_plugins(
root: &Path,
config_plugin_paths: &[String],
) -> Vec<ExternalPluginDef> {
let mut plugins = Vec::new();
let mut seen_names = rustc_hash::FxHashSet::default();
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
load_configured_plugin_paths(
root,
config_plugin_paths,
&canonical_root,
&mut plugins,
&mut seen_names,
);
load_default_plugins_dir(root, &canonical_root, &mut plugins, &mut seen_names);
load_root_plugin_files(root, &canonical_root, &mut plugins, &mut seen_names);
plugins
}
fn load_configured_plugin_paths(
root: &Path,
config_plugin_paths: &[String],
canonical_root: &Path,
plugins: &mut Vec<ExternalPluginDef>,
seen_names: &mut rustc_hash::FxHashSet<String>,
) {
for path_str in config_plugin_paths {
let path = root.join(path_str);
if !is_within_root(&path, canonical_root) {
tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
continue;
}
if path.is_dir() {
load_plugins_from_dir(&path, canonical_root, plugins, seen_names);
} else if path.is_file() {
load_plugin_file(&path, canonical_root, plugins, seen_names);
}
}
}
fn load_default_plugins_dir(
root: &Path,
canonical_root: &Path,
plugins: &mut Vec<ExternalPluginDef>,
seen_names: &mut rustc_hash::FxHashSet<String>,
) {
let plugins_dir = root.join(".fallow").join(DEFAULT_PLUGINS_DIR_NAME);
if plugins_dir.is_dir() && is_within_root(&plugins_dir, canonical_root) {
load_plugins_from_dir(&plugins_dir, canonical_root, plugins, seen_names);
}
}
fn load_root_plugin_files(
root: &Path,
canonical_root: &Path,
plugins: &mut Vec<ExternalPluginDef>,
seen_names: &mut rustc_hash::FxHashSet<String>,
) {
for path in root_plugin_files(root) {
load_plugin_file(&path, canonical_root, plugins, seen_names);
}
}
fn root_plugin_files(root: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut plugin_files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.is_file()
&& p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
n.starts_with(ROOT_PLUGIN_FILE_PREFIX) && is_plugin_file(Path::new(n))
})
})
.collect();
plugin_files.sort();
plugin_files
}
fn plugin_files_in_dir(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut plugin_files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.is_file() && is_plugin_file(p))
.collect();
plugin_files.sort();
plugin_files
}
#[must_use]
pub fn is_default_external_plugin_file(path: &Path) -> bool {
if !is_plugin_file(path) {
return false;
}
let name_matches = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(ROOT_PLUGIN_FILE_PREFIX));
let parent = path.parent();
let in_plugins_dir = parent
.and_then(Path::file_name)
.is_some_and(|name| name == DEFAULT_PLUGINS_DIR_NAME)
&& parent
.and_then(Path::parent)
.and_then(Path::file_name)
.is_some_and(|name| name == ".fallow");
name_matches || in_plugins_dir
}
#[must_use]
pub fn external_plugin_source_files(root: &Path, config_plugin_paths: &[String]) -> Vec<PathBuf> {
let mut files = Vec::new();
for path_str in config_plugin_paths {
let path = root.join(path_str);
if path.is_dir() {
files.extend(plugin_files_in_dir(&path));
} else {
files.push(path);
}
}
files.extend(plugin_files_in_dir(
&root.join(".fallow").join(DEFAULT_PLUGINS_DIR_NAME),
));
files.extend(root_plugin_files(root));
files
}
#[expect(
clippy::redundant_pub_crate,
reason = "this module is glob re-exported from lib.rs, so `pub` would leak this helper into the public API; pub(crate) is the minimal widening for the rule-pack loader"
)]
pub(crate) fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
canonical.starts_with(canonical_root)
}
fn load_plugins_from_dir(
dir: &Path,
canonical_root: &Path,
plugins: &mut Vec<ExternalPluginDef>,
seen: &mut rustc_hash::FxHashSet<String>,
) {
for path in plugin_files_in_dir(dir) {
load_plugin_file(&path, canonical_root, plugins, seen);
}
}
fn load_plugin_file(
path: &Path,
canonical_root: &Path,
plugins: &mut Vec<ExternalPluginDef>,
seen: &mut rustc_hash::FxHashSet<String>,
) {
if !is_within_root(path, canonical_root) {
tracing::warn!(
"plugin file '{}' resolves outside project root (symlink?), skipping",
path.display()
);
return;
}
let Some(format) = PluginFormat::from_path(path) else {
tracing::warn!(
"unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
path.display()
);
return;
};
let Some(content) = read_plugin_file(path) else {
return;
};
if let Some(plugin) = parse_plugin(&content, &format, path) {
push_plugin_if_unique(plugin, path, plugins, seen);
}
}
fn read_plugin_file(path: &Path) -> Option<String> {
match std::fs::read_to_string(path) {
Ok(content) => Some(content),
Err(e) => {
tracing::warn!(
"failed to read external plugin file {}: {e}",
path.display()
);
None
}
}
}
fn push_plugin_if_unique(
plugin: ExternalPluginDef,
path: &Path,
plugins: &mut Vec<ExternalPluginDef>,
seen: &mut rustc_hash::FxHashSet<String>,
) {
if plugin.name.is_empty() {
tracing::warn!(
"external plugin in {} has an empty name, skipping",
path.display()
);
return;
}
if seen.insert(plugin.name.clone()) {
plugins.push(plugin);
} else {
tracing::warn!(
"duplicate external plugin '{}' in {}, skipping",
plugin.name,
path.display()
);
}
}
#[cfg(test)]
mod tests;