use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::sync::{Arc, LazyLock};
use serde::Deserialize;
use crate::pattern::matches_glob;
#[derive(Debug, Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct PatternCheck {
#[serde(default)]
pub enabled: bool,
#[serde(default, deserialize_with = "deserialize_arc_str_slice")]
pub patterns: Arc<[Arc<str>]>,
}
impl PatternCheck {
pub fn apply_override(&mut self, ovr: &PatternOverride) {
if let Some(enabled) = ovr.enabled {
self.enabled = enabled;
}
if !ovr.patterns.is_empty() {
self.patterns = ovr.patterns.clone();
}
}
}
fn deserialize_arc_str_slice<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Arc<[Arc<str>]>, D::Error> {
let strings: Vec<String> = Vec::deserialize(deserializer)?;
Ok(strings.into_iter().map(Arc::from).collect())
}
const DEFAULT_GENERIC_NAMES: &[&str] = &[
"tmp", "temp", "data", "val", "value", "result", "res", "ret", "buf", "buffer", "item", "elem",
"obj", "input", "output", "info", "ctx", "args", "params", "thing", "stuff", "foo", "bar",
"baz",
];
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct NamingCheck {
#[serde(default)]
pub enabled: bool,
#[serde(
default = "default_generic_names",
deserialize_with = "deserialize_arc_str_slice"
)]
pub generic_names: Arc<[Arc<str>]>,
#[serde(default = "default_max_generic_ratio")]
pub max_generic_ratio: f64,
#[serde(default = "default_min_generic_count")]
pub min_generic_count: usize,
}
impl Default for NamingCheck {
fn default() -> Self {
Self {
enabled: false,
generic_names: default_generic_names(),
max_generic_ratio: default_max_generic_ratio(),
min_generic_count: default_min_generic_count(),
}
}
}
impl NamingCheck {
pub fn apply_override(&mut self, ovr: &NamingOverride) {
if let Some(enabled) = ovr.enabled {
self.enabled = enabled;
}
if let Some(ref names) = ovr.generic_names {
self.generic_names = names.clone();
}
if let Some(ratio) = ovr.max_generic_ratio {
self.max_generic_ratio = ratio;
}
if let Some(count) = ovr.min_generic_count {
self.min_generic_count = count;
}
}
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct NamingOverride {
pub enabled: Option<bool>,
#[serde(default, deserialize_with = "deserialize_option_arc_str_slice")]
pub generic_names: Option<Arc<[Arc<str>]>>,
pub max_generic_ratio: Option<f64>,
pub min_generic_count: Option<usize>,
}
static GENERIC_NAMES_ARC: LazyLock<Arc<[Arc<str>]>> = LazyLock::new(|| {
DEFAULT_GENERIC_NAMES
.iter()
.map(|s| Arc::from(*s))
.collect()
});
fn default_generic_names() -> Arc<[Arc<str>]> {
Arc::clone(&GENERIC_NAMES_ARC)
}
type ArcStrSlice = Arc<[Arc<str>]>;
fn deserialize_option_arc_str_slice<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<ArcStrSlice>, D::Error> {
let opt: Option<Vec<String>> = Option::deserialize(deserializer)?;
Ok(opt.map(|v| v.into_iter().map(Arc::from).collect()))
}
fn default_max_generic_ratio() -> f64 {
0.3
}
fn default_min_generic_count() -> usize {
2
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct PatternOverride {
pub enabled: Option<bool>,
#[serde(default, deserialize_with = "deserialize_arc_str_slice")]
pub patterns: Arc<[Arc<str>]>,
}
#[derive(Debug, Deserialize, Default)]
pub struct ConfigFile {
#[serde(default)]
pub gate: GateConfig,
#[serde(default = "default_max_depth")]
pub max_depth: usize,
#[serde(default = "default_else_chain_threshold")]
pub else_chain_threshold: usize,
#[serde(default)]
pub forbid_attributes: PatternCheck,
#[serde(default)]
pub forbid_types: PatternCheck,
#[serde(default)]
pub forbid_calls: PatternCheck,
#[serde(default)]
pub forbid_macros: PatternCheck,
#[serde(default)]
pub check_naming: NamingCheck,
#[serde(default = "default_true")]
pub check_nested_if: bool,
#[serde(default = "default_true")]
pub check_if_in_match: bool,
#[serde(default = "default_true")]
pub check_nested_match: bool,
#[serde(default = "default_true")]
pub check_match_in_if: bool,
#[serde(default = "default_true")]
pub check_else_chain: bool,
#[serde(default)]
pub forbid_else: bool,
#[serde(default = "default_true")]
pub forbid_unsafe: bool,
#[serde(default)]
pub check_dyn_return: bool,
#[serde(default)]
pub check_dyn_param: bool,
#[serde(default)]
pub check_vec_box_dyn: bool,
#[serde(default)]
pub check_dyn_field: bool,
#[serde(default)]
pub check_clone_in_loop: bool,
#[serde(default)]
pub check_default_hasher: bool,
#[serde(default)]
pub check_mixed_concerns: bool,
#[serde(default)]
pub check_inline_tests: bool,
#[serde(default)]
pub check_let_underscore_result: bool,
#[serde(default)]
pub overrides: BTreeMap<Box<str>, PathOverride>,
}
#[derive(Debug, Deserialize, Default)]
pub struct PathOverride {
pub enabled: Option<bool>,
pub max_depth: Option<usize>,
pub forbid_attributes: Option<PatternOverride>,
pub forbid_types: Option<PatternOverride>,
pub forbid_calls: Option<PatternOverride>,
pub forbid_macros: Option<PatternOverride>,
pub check_naming: Option<NamingOverride>,
pub check_nested_if: Option<bool>,
pub check_if_in_match: Option<bool>,
pub check_nested_match: Option<bool>,
pub check_match_in_if: Option<bool>,
pub check_else_chain: Option<bool>,
pub forbid_else: Option<bool>,
pub forbid_unsafe: Option<bool>,
pub check_dyn_return: Option<bool>,
pub check_dyn_param: Option<bool>,
pub check_vec_box_dyn: Option<bool>,
pub check_dyn_field: Option<bool>,
pub check_clone_in_loop: Option<bool>,
pub check_default_hasher: Option<bool>,
pub check_mixed_concerns: Option<bool>,
pub check_inline_tests: Option<bool>,
pub check_let_underscore_result: Option<bool>,
}
fn default_max_depth() -> usize {
3
}
fn default_else_chain_threshold() -> usize {
3
}
fn default_true() -> bool {
true
}
pub fn check_path_override<'a>(
file_path: &str,
config: &'a ConfigFile,
) -> Option<&'a PathOverride> {
for (pattern, override_config) in &config.overrides {
if matches_glob(pattern, file_path) {
return Some(override_config);
}
}
None
}
macro_rules! for_each_bool_check {
($callback:ident!) => {
$callback! {
"Flag `if` inside `if`.", check_nested_if, true;
"Flag `if` inside `match` arm.", check_if_in_match, true;
"Flag `match` inside `match`.", check_nested_match, true;
"Flag `match` inside `if` branch.", check_match_in_if, true;
"Flag long `if/else if` chains.", check_else_chain, true;
"Flag any use of the `else` keyword.", forbid_else, false;
"Flag any `unsafe` block.", forbid_unsafe, true;
"Flag dynamic dispatch in return types.", check_dyn_return, false;
"Flag dynamic dispatch in function parameters.", check_dyn_param, false;
"Flag `Vec<Box<dyn T>>`.", check_vec_box_dyn, false;
"Flag dynamic dispatch in struct fields.", check_dyn_field, false;
"Flag `.clone()` inside loop bodies.", check_clone_in_loop, false;
"Flag `HashMap`/`HashSet` with default hasher.", check_default_hasher, false;
"Flag disconnected type groups in a single file.", check_mixed_concerns, false;
"Flag `#[cfg(test)] mod` blocks in source files.", check_inline_tests, false;
"Flag `let _ = expr` that discards a Result.", check_let_underscore_result, false;
}
};
}
macro_rules! impl_check_config {
($($doc:literal, $field:ident, $default:expr;)*) => {
#[derive(Debug, Clone)]
pub struct CheckConfig {
pub max_depth: usize,
pub else_chain_threshold: usize,
pub forbid_attributes: PatternCheck,
pub forbid_types: PatternCheck,
pub forbid_calls: PatternCheck,
pub forbid_macros: PatternCheck,
pub check_naming: NamingCheck,
$(
#[doc = $doc]
pub $field: bool,
)*
}
impl Default for CheckConfig {
fn default() -> Self {
Self {
max_depth: 3,
else_chain_threshold: 3,
forbid_attributes: PatternCheck::default(),
forbid_types: PatternCheck::default(),
forbid_calls: PatternCheck::default(),
forbid_macros: PatternCheck::default(),
check_naming: NamingCheck::default(),
$( $field: $default, )*
}
}
}
impl CheckConfig {
pub fn from_config_file(fc: &ConfigFile) -> Self {
Self {
max_depth: fc.max_depth,
else_chain_threshold: fc.else_chain_threshold,
forbid_attributes: fc.forbid_attributes.clone(),
forbid_types: fc.forbid_types.clone(),
forbid_calls: fc.forbid_calls.clone(),
forbid_macros: fc.forbid_macros.clone(),
check_naming: fc.check_naming.clone(),
$( $field: fc.$field, )*
}
}
pub fn merge_bool_overrides(&mut self, ovr: &PathOverride) {
$(
if let Some(v) = ovr.$field {
self.$field = v;
}
)*
}
}
};
}
for_each_bool_check!(impl_check_config!);
macro_rules! assert_bool_fields_in_sync {
($($doc:literal, $field:ident, $default:expr;)*) => {
#[cfg(test)]
const _: () = {
$(
const fn $field(cf: &ConfigFile, po: &PathOverride) -> (bool, Option<bool>) {
(cf.$field, po.$field)
}
)*
};
};
}
for_each_bool_check!(assert_bool_fields_in_sync!);
impl CheckConfig {
pub fn resolve_for_path<'a>(
&'a self,
file_path: &str,
file_config: Option<&ConfigFile>,
) -> Option<Cow<'a, Self>> {
let Some(fc) = file_config else {
return Some(Cow::Borrowed(self));
};
let Some(override_cfg) = check_path_override(file_path, fc) else {
return Some(Cow::Borrowed(self));
};
if override_cfg.enabled == Some(false) {
return None;
}
let mut config = self.clone();
if let Some(max_depth) = override_cfg.max_depth {
config.max_depth = max_depth;
}
config.merge_bool_overrides(override_cfg);
macro_rules! apply {
($field:ident) => {
if let Some(ref ovr) = override_cfg.$field {
config.$field.apply_override(ovr);
}
};
}
apply!(forbid_attributes);
apply!(forbid_types);
apply!(forbid_calls);
apply!(forbid_macros);
apply!(check_naming);
Some(Cow::Owned(config))
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file: {0}")]
Read(#[from] std::io::Error),
#[error("failed to parse config file: {0}")]
Parse(#[from] toml::de::Error),
}
pub fn load_config_file(path: &Path) -> Result<ConfigFile, ConfigError> {
let content = fs::read_to_string(path)?;
Ok(toml::from_str(&content)?)
}
pub fn find_config_file() -> Option<std::path::PathBuf> {
find_project_config_file().or_else(find_global_config_file)
}
fn find_project_config_file() -> Option<std::path::PathBuf> {
let config_path = std::env::current_dir().ok()?.join(".pedant.toml");
config_path.exists().then_some(config_path)
}
fn find_global_config_file() -> Option<std::path::PathBuf> {
let config_dir = std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
})?;
let config_path = config_dir.join("pedant").join("config.toml");
config_path.exists().then_some(config_path)
}
#[derive(Debug)]
pub enum GateRuleOverride {
Disabled,
Severity(crate::gate::GateSeverity),
}
#[derive(Debug)]
pub struct GateConfig {
pub enabled: bool,
pub overrides: BTreeMap<Box<str>, GateRuleOverride>,
}
impl Default for GateConfig {
fn default() -> Self {
Self {
enabled: true,
overrides: BTreeMap::new(),
}
}
}
impl<'de> Deserialize<'de> for GateConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
#[derive(Deserialize)]
#[serde(untagged)]
enum GateTomlValue {
Bool(bool),
String(String),
}
let raw: BTreeMap<Box<str>, GateTomlValue> = BTreeMap::deserialize(deserializer)?;
let mut enabled = true;
let mut overrides = BTreeMap::new();
for (key, value) in raw {
match (&*key, value) {
("enabled", GateTomlValue::Bool(b)) => enabled = b,
("enabled", GateTomlValue::String(_)) => {
return Err(D::Error::custom("'enabled' must be a boolean"));
}
(_, GateTomlValue::Bool(false)) => {
overrides.insert(key, GateRuleOverride::Disabled);
}
(_, GateTomlValue::Bool(true)) => {} (_, GateTomlValue::String(s)) => {
let severity = parse_gate_severity(&s).ok_or_else(|| {
D::Error::custom(format!(
"invalid gate severity '{s}': expected \"deny\", \"warn\", or \"info\""
))
})?;
overrides.insert(key, GateRuleOverride::Severity(severity));
}
}
}
Ok(GateConfig { enabled, overrides })
}
}
fn parse_gate_severity(s: &str) -> Option<crate::gate::GateSeverity> {
use crate::gate::GateSeverity;
match s {
"deny" => Some(GateSeverity::Deny),
"warn" => Some(GateSeverity::Warn),
"info" => Some(GateSeverity::Info),
_ => None,
}
}