use crate::core::backend::GeneratedFile;
use crate::core::config::tools::ruby_bundle_exec;
use crate::core::config::{Language, ResolvedCrateConfig};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExcludeScope {
RepoRoot,
AnyDepth,
}
#[derive(Debug, Clone)]
struct ExcludeEntry {
pattern: String,
scope: ExcludeScope,
}
impl ExcludeEntry {
fn root(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
scope: ExcludeScope::RepoRoot,
}
}
fn any_depth(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
scope: ExcludeScope::AnyDepth,
}
}
fn for_discovery(&self) -> String {
match self.scope {
ExcludeScope::RepoRoot => format!("/{}", self.pattern),
ExcludeScope::AnyDepth => format!("**/{}", self.pattern),
}
}
fn for_hooks(&self) -> String {
match self.scope {
ExcludeScope::RepoRoot => self.pattern.clone(),
ExcludeScope::AnyDepth => format!("**/{}", self.pattern),
}
}
}
fn classify_extra(raw: &str) -> ExcludeEntry {
if let Some(rest) = raw.strip_prefix("**/") {
return ExcludeEntry::any_depth(rest);
}
if let Some(rest) = raw.strip_prefix("./") {
return ExcludeEntry::root(rest);
}
if let Some(rest) = raw.strip_prefix('/') {
return ExcludeEntry::root(rest);
}
ExcludeEntry::root(raw)
}
const EXCLUDES: &[(&str, ExcludeScope)] = &[
("*.freezed.dart", ExcludeScope::AnyDepth),
("*.g.dart", ExcludeScope::AnyDepth),
("*.jinja", ExcludeScope::AnyDepth),
("*.lock", ExcludeScope::AnyDepth),
("Cargo.lock", ExcludeScope::AnyDepth),
("go.sum", ExcludeScope::AnyDepth),
("package-lock.json", ExcludeScope::AnyDepth),
("pnpm-lock.yaml", ExcludeScope::AnyDepth),
("uv.lock", ExcludeScope::AnyDepth),
(".alef/**", ExcludeScope::RepoRoot),
("artifacts/**", ExcludeScope::AnyDepth),
("dist/**", ExcludeScope::AnyDepth),
("fixtures/**", ExcludeScope::RepoRoot),
("node_modules/**", ExcludeScope::AnyDepth),
("readme_templates/**", ExcludeScope::RepoRoot),
("schemas/**", ExcludeScope::RepoRoot),
("target/**", ExcludeScope::AnyDepth),
("templates/readme/**", ExcludeScope::RepoRoot),
("test_documents/**", ExcludeScope::RepoRoot),
("vendor/**", ExcludeScope::AnyDepth),
];
fn docs_snippets_excludes(config: &ResolvedCrateConfig) -> Vec<ExcludeEntry> {
let mut dirs: Vec<String> = config
.docs
.as_ref()
.and_then(|docs| docs.snippets.as_ref())
.into_iter()
.flat_map(|snippets| snippets.dirs.iter())
.map(|dir| dir.to_string_lossy().trim_end_matches('/').to_string())
.collect();
if let Some(output) = config
.e2e
.as_ref()
.and_then(|e2e| e2e.snippets.as_ref())
.map(|snippets| snippets.output.trim_end_matches('/'))
&& !output.is_empty()
&& !dirs.iter().any(|dir| dir.as_str() == output)
{
dirs.push(output.to_string());
}
dirs.into_iter()
.map(|dir| ExcludeEntry::root(format!("{dir}/**")))
.collect()
}
const POLY_FORMAT_EXCLUDES: &[(&str, ExcludeScope)] = &[("Cargo.toml", ExcludeScope::AnyDepth)];
const CLANG_FORMAT: &str = "\
---
BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 100
BreakBeforeBraces: Attach
AllowShortFunctionsOnASingleLine: Empty
AllowShortIfStatementsOnASingleLine: false
SortIncludes: true
";
const RUFF_SELECT: &[&str] = &[
"F", "E", "W", "I", "N", "D", "UP", "ANN", "ASYNC", "S", "B", "A", "C4", "DTZ", "T10", "T20", "ISC", "ICN", "PIE",
"PT", "Q", "RSE", "RET", "SIM", "TID", "TC", "ARG", "PTH", "PGH", "PL", "PERF", "FURB", "RUF",
];
const RUFF_IGNORE: &[&str] = &[
"ANN401", "ASYNC109", "ASYNC110", "D100", "D104", "D107", "D205", "E501", "ISC001", "PGH003", "PLR2004", "PLW0603",
"S104", "S110", "S603",
];
const RUMDL_DISABLE: &[&str] = &[
"MD012", "MD013", "MD024", "MD033", "MD036", "MD041", "MD046", "MD051", "MD076",
];
const RUMDL_FMT_ONLY_DISABLE: &[&str] = &["MD025"];
const MAGO_IGNORE: &[&str] = &[
"strict-assertions",
"use-specific-assertions",
"no-redundant-variable",
"sensitive-parameter",
];
const TEST_IGNORES: &[&str] = &[
"ANN",
"D103",
"PLR2004",
"PLR0915",
"PLR0913",
"S101",
"S105",
"S106",
"S108",
"S310",
"S311",
"PT011",
"PT012",
"PERF401",
"PTH123",
"T201",
"TC001",
"TC002",
"TC003",
"INP001",
"no-unused-vars",
"no-literal-password",
"no-unescaped-output",
"RUF100",
"F401",
"F811",
"I001",
"PT018",
"ARG001",
"ARG002",
"D403",
"E713",
"UP035",
"UP012",
"RUF015",
"F541",
"EXE001",
"A001",
"N801",
];
fn toml_array<S: AsRef<str>>(entries: &[S]) -> String {
if entries.is_empty() {
return "[]".to_string();
}
let inner = entries
.iter()
.map(|e| format!(" \"{}\",", e.as_ref()))
.collect::<Vec<_>>()
.join("\n");
format!("[\n{inner}\n]")
}
fn portable_dir(dir: &str) -> String {
dir.replace('\\', "/")
}
fn workspace_hook(name: &str, dir: &str, run: &str, files_glob: &str) -> String {
let dir = portable_dir(dir);
format!(
"\n[hooks.pre-commit.commands.{name}]\n\
run = \"{run}\"\n\
root = \"{dir}\"\n\
workspace = true\n\
files = \"{dir}/{files_glob}\"\n"
)
}
pub(crate) fn scaffold_poly_config(config: &ResolvedCrateConfig, languages: &[Language]) -> Vec<GeneratedFile> {
let has = |lang: Language| languages.contains(&lang);
let mut all_excludes: Vec<ExcludeEntry> = EXCLUDES
.iter()
.chain(POLY_FORMAT_EXCLUDES.iter())
.map(|(pattern, scope)| ExcludeEntry {
pattern: (*pattern).to_string(),
scope: *scope,
})
.collect();
all_excludes.extend(docs_snippets_excludes(config));
all_excludes.extend(config.poly.exclude.iter().map(|raw| classify_extra(raw)));
let discovery_excludes: Vec<String> = all_excludes.iter().map(ExcludeEntry::for_discovery).collect();
let hooks_excludes: Vec<String> = all_excludes.iter().map(ExcludeEntry::for_hooks).collect();
let discovery_excludes_toml = toml_array(&discovery_excludes);
let hooks_excludes_toml = toml_array(&hooks_excludes);
let file_safety_excludes = if config.poly.file_safety_exclude.is_empty() {
hooks_excludes_toml.clone()
} else {
let mut entries = all_excludes.clone();
entries.extend(config.poly.file_safety_exclude.iter().map(|raw| classify_extra(raw)));
let lowered: Vec<String> = entries.iter().map(ExcludeEntry::for_hooks).collect();
toml_array(&lowered)
};
let mut out = String::new();
out.push_str(&format!("[discovery]\nexclude = {discovery_excludes_toml}\n\n"));
if let Some(workspace) = config.poly.lint_workspace {
out.push_str(&format!("[lint]\nworkspace = {workspace}\n\n"));
}
let md_lint_disable = toml_array(RUMDL_DISABLE);
let md_fmt_disable = toml_array(
&RUMDL_DISABLE
.iter()
.chain(RUMDL_FMT_ONLY_DISABLE.iter())
.copied()
.collect::<Vec<_>>(),
);
out.push_str(&format!("[lint.markdown.rumdl]\ndisable = {md_lint_disable}\n\n"));
out.push_str(&format!("[fmt.markdown.rumdl]\ndisable = {md_fmt_disable}\n\n"));
if has(Language::Python) {
out.push_str(&format!(
"[lint.python.ruff]\nselect = {select}\nignore = {ignore}\n",
select = toml_array(RUFF_SELECT),
ignore = toml_array(RUFF_IGNORE)
));
out.push_str(
"mccabe_max_complexity = 15\n\
pydocstyle_convention = \"google\"\n\
pylint_max_args = 10\n\
pylint_max_branches = 15\n\
pylint_max_returns = 10\n\n",
);
}
if has(Language::Php) {
out.push_str(&format!(
"[lint.php.mago]\nselect = [\"correctness\", \"security\"]\nignore = {ignore}\nphp_version = \"8.2\"\n\n",
ignore = toml_array(MAGO_IGNORE)
));
}
if !config.poly.typos.extend_words.is_empty() {
out.push_str("[lint.typos.extend_words]\n");
for (word, correct) in &config.poly.typos.extend_words {
out.push_str(&format!("{word} = \"{correct}\"\n"));
}
out.push('\n');
}
if !config.poly.typos.extend_identifiers.is_empty() {
out.push_str("[lint.typos.extend_identifiers]\n");
for (ident, correct) in &config.poly.typos.extend_identifiers {
out.push_str(&format!("{ident} = \"{correct}\"\n"));
}
out.push('\n');
}
if let Some(uncomment) = &config.poly.uncomment {
out.push_str("[lint.uncomment]\n");
out.push_str(&format!("enabled = {}\n", uncomment.enabled));
out.push_str(&format!("remove_todos = {}\n", uncomment.remove_todos));
out.push_str(&format!("remove_fixme = {}\n", uncomment.remove_fixme));
out.push_str(&format!("remove_docs = {}\n", uncomment.remove_docs));
out.push_str(&format!("use_default_ignores = {}\n", uncomment.use_default_ignores));
let patterns: Vec<&str> = uncomment.preserve_patterns.iter().map(String::as_str).collect();
out.push_str(&format!("preserve_patterns = {}\n\n", toml_array(&patterns)));
}
if has(Language::Ffi) {
out.push_str("[tools.clang-format]\nenabled = true\n\n");
}
if has(Language::Elixir) {
let dir = portable_dir(&config.package_dir(Language::Elixir));
out.push_str(&format!("[tools.mix]\nenabled = true\nroot = \"{dir}\"\n\n"));
}
out.push_str("[per-file-ignores]\n");
if has(Language::Python) {
out.push_str(
"\"**/api.py\" = [ \"F401\", \"I001\", \"TC006\", \"UP035\" ]\n\
\"**/*.pyi\" = [ \"A002\", \"F401\", \"I001\", \"PYI033\", \"TC006\", \"UP035\" ]\n\
\"**/options.py\" = [ \"F401\", \"I001\", \"RUF100\" ]\n\
\"**/__init__.py\" = [ \"I001\" ]\n",
);
}
let test_ignores = toml_array(TEST_IGNORES);
for glob in ["**/tests/**", "**/e2e/**", "**/test_apps/**"] {
out.push_str(&format!("\"{glob}\" = {test_ignores}\n"));
}
for (glob, codes) in &config.poly.per_file_ignores {
let code_refs: Vec<&str> = codes.iter().map(String::as_str).collect();
out.push_str(&format!("\"{glob}\" = {}\n", toml_array(&code_refs)));
}
out.push('\n');
out.push_str("[hooks]\nstages = [\"pre-commit\"]\n\n[hooks.builtin]\n");
out.push_str(&format!("lint = {{ exclude = {hooks_excludes_toml} }}\n"));
out.push_str(&format!("fmt = {{ exclude = {hooks_excludes_toml} }}\n"));
out.push_str(&format!("file_safety = {{ exclude = {file_safety_excludes} }}\n"));
out.push_str("cargo = true\n");
out.push_str("commit = { stages = [\"commit-msg\"] }\n");
if has(Language::Python) {
let py_dir = portable_dir(&config.package_dir(Language::Python));
out.push_str(&format!(
"\n[hooks.pre-commit.commands.pyrefly]\nrun = \"pyrefly check {py_dir}\"\nworkspace = true\nfiles = \"{py_dir}/**/*.py\"\n"
));
}
if has(Language::Ruby) {
let dir = config.package_dir(Language::Ruby);
let rubocop = ruby_bundle_exec("rubocop");
let steep = ruby_bundle_exec("steep check");
out.push_str(&workspace_hook("rubocop", &dir, &rubocop, "**/*.rb"));
out.push_str(&workspace_hook("steep", &dir, &steep, "**/*.rb"));
}
if has(Language::Go) {
let dir = config.package_dir(Language::Go);
out.push_str(&workspace_hook(
"golangci-lint",
&dir,
"golangci-lint run ./...",
"**/*.go",
));
}
if has(Language::Java) {
let dir = config.package_dir(Language::Java);
out.push_str(&workspace_hook(
"checkstyle",
&dir,
"mvn -q checkstyle:check",
"**/*.java",
));
}
if has(Language::Dart) {
let dir = config.package_dir(Language::Dart);
out.push_str(&workspace_hook("dart-analyze", &dir, "dart analyze", "**/*.dart"));
}
if has(Language::Elixir) {
let dir = config.package_dir(Language::Elixir);
out.push_str(&workspace_hook(
"credo",
&dir,
"mix deps.get && mix credo --strict",
"**/*.{ex,exs}",
));
}
for source in &config.poly.hooks_sources {
let hook_refs: Vec<&str> = source.hooks.iter().map(String::as_str).collect();
out.push_str(&format!(
"\n[[hooks.sources]]\nid = \"{}\"\ngit = \"{}\"\nrevision = \"{}\"\nhooks = {}\n",
source.id,
source.git,
source.revision,
toml_array(&hook_refs),
));
}
let mut files = vec![
GeneratedFile {
path: PathBuf::from("poly.toml"),
content: out,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from("rustfmt.toml"),
content: "max_width = 120\n".to_string(),
generated_header: true,
},
];
if has(Language::Ffi) {
files.push(GeneratedFile {
path: PathBuf::from(".clang-format"),
content: CLANG_FORMAT.to_string(),
generated_header: true,
});
}
files
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::ResolvedCrateConfig;
fn poly_toml() -> String {
scaffold_poly_config(&ResolvedCrateConfig::default(), &[Language::Rust])
.into_iter()
.find(|file| file.path == std::path::Path::new("poly.toml"))
.expect("scaffold emits poly.toml")
.content
}
fn disable_list(content: &str, table: &str) -> String {
let start = content
.find(table)
.unwrap_or_else(|| panic!("{table} present in poly.toml"));
let rest = &content[start + table.len()..];
let open = rest.find('[').expect("disable array opens");
let close = rest[open..].find(']').expect("disable array closes") + open;
rest[open..=close].to_string()
}
#[test]
fn should_disable_md025_for_fmt_only() {
let content = poly_toml();
assert!(
disable_list(&content, "[fmt.markdown.rumdl]").contains("\"MD025\""),
"fmt must not run MD025's autofix: it demotes every heading after a stray H1"
);
assert!(
!disable_list(&content, "[lint.markdown.rumdl]").contains("\"MD025\""),
"lint must keep reporting MD025 so a stray H1 is still surfaced"
);
}
#[test]
fn should_keep_shared_rumdl_disables_in_both_tables() {
let content = poly_toml();
let lint = disable_list(&content, "[lint.markdown.rumdl]");
let fmt = disable_list(&content, "[fmt.markdown.rumdl]");
for rule in RUMDL_DISABLE {
assert!(
lint.contains(&format!("\"{rule}\"")),
"{rule} missing from lint disable list"
);
assert!(
fmt.contains(&format!("\"{rule}\"")),
"{rule} missing from fmt disable list"
);
}
}
#[test]
fn portable_dir_replaces_every_backslash_with_a_forward_slash() {
assert_eq!(portable_dir("packages\\java"), "packages/java");
assert_eq!(portable_dir("packages/java"), "packages/java");
assert_eq!(portable_dir("sdk\\java/nested\\deep"), "sdk/java/nested/deep");
}
#[test]
fn workspace_hook_normalizes_a_windows_separated_dir_into_valid_toml() {
let hook = workspace_hook("checkstyle", "packages\\java", "mvn -q checkstyle:check", "**/*.java");
assert!(
!hook.contains('\\'),
"no backslash may reach the emitted poly.toml text: {hook}"
);
assert!(hook.contains("root = \"packages/java\""));
assert!(hook.contains("files = \"packages/java/**/*.java\""));
let wrapped = format!("[hooks]\nstages = [\"pre-commit\"]\n{hook}");
toml::from_str::<toml::Value>(&wrapped).expect("normalized hook text must be valid TOML");
}
}