Skip to main content

pedant_core/
check_config.rs

1use std::borrow::Cow;
2use std::collections::BTreeMap;
3use std::fs;
4use std::path::Path;
5use std::sync::{Arc, LazyLock};
6
7use serde::Deserialize;
8
9use crate::pattern::matches_glob;
10
11/// A set of glob patterns matched against rendered AST node text.
12#[derive(Debug, Deserialize, Default, Clone)]
13#[serde(deny_unknown_fields)]
14pub struct PatternCheck {
15    /// Master switch; `false` skips all patterns.
16    #[serde(default)]
17    pub enabled: bool,
18    /// Glob patterns (e.g., `.unwrap()`, `allow(dead_code)`).
19    #[serde(default, deserialize_with = "deserialize_arc_str_slice")]
20    pub patterns: Arc<[Arc<str>]>,
21}
22
23impl PatternCheck {
24    /// Merge a path-specific override, replacing fields that are set.
25    pub fn apply_override(&mut self, ovr: &PatternOverride) {
26        if let Some(enabled) = ovr.enabled {
27            self.enabled = enabled;
28        }
29        if !ovr.patterns.is_empty() {
30            self.patterns = ovr.patterns.clone();
31        }
32    }
33}
34
35fn deserialize_arc_str_slice<'de, D: serde::Deserializer<'de>>(
36    deserializer: D,
37) -> Result<Arc<[Arc<str>]>, D::Error> {
38    let strings: Vec<String> = Vec::deserialize(deserializer)?;
39    Ok(strings.into_iter().map(Arc::from).collect())
40}
41
42/// Default list of generic variable names that LLMs overuse.
43const DEFAULT_GENERIC_NAMES: &[&str] = &[
44    "tmp", "temp", "data", "val", "value", "result", "res", "ret", "buf", "buffer", "item", "elem",
45    "obj", "input", "output", "info", "ctx", "args", "params", "thing", "stuff", "foo", "bar",
46    "baz",
47];
48
49/// Thresholds for the generic-naming check (`tmp`, `val`, `data`, etc.).
50#[derive(Debug, Deserialize, Clone)]
51#[serde(deny_unknown_fields)]
52pub struct NamingCheck {
53    /// Master switch; `false` skips the naming check entirely.
54    #[serde(default)]
55    pub enabled: bool,
56    /// Words considered generic. Replaces the built-in list when provided.
57    #[serde(
58        default = "default_generic_names",
59        deserialize_with = "deserialize_arc_str_slice"
60    )]
61    pub generic_names: Arc<[Arc<str>]>,
62    /// Fraction of bindings that must be generic before flagging (0.0..=1.0).
63    #[serde(default = "default_max_generic_ratio")]
64    pub max_generic_ratio: f64,
65    /// Absolute minimum generic count before the ratio check kicks in.
66    #[serde(default = "default_min_generic_count")]
67    pub min_generic_count: usize,
68}
69
70impl Default for NamingCheck {
71    fn default() -> Self {
72        Self {
73            enabled: false,
74            generic_names: default_generic_names(),
75            max_generic_ratio: default_max_generic_ratio(),
76            min_generic_count: default_min_generic_count(),
77        }
78    }
79}
80
81impl NamingCheck {
82    /// Merge a path-specific override, replacing fields that are set.
83    pub fn apply_override(&mut self, ovr: &NamingOverride) {
84        if let Some(enabled) = ovr.enabled {
85            self.enabled = enabled;
86        }
87        if let Some(ref names) = ovr.generic_names {
88            self.generic_names = names.clone();
89        }
90        if let Some(ratio) = ovr.max_generic_ratio {
91            self.max_generic_ratio = ratio;
92        }
93        if let Some(count) = ovr.min_generic_count {
94            self.min_generic_count = count;
95        }
96    }
97}
98
99/// Path-specific overrides for the naming check. `None` inherits from base config.
100#[derive(Debug, Deserialize, Default, Clone)]
101#[serde(deny_unknown_fields)]
102pub struct NamingOverride {
103    /// Replace the enabled state.
104    pub enabled: Option<bool>,
105    /// Replace the generic names list.
106    #[serde(default, deserialize_with = "deserialize_option_arc_str_slice")]
107    pub generic_names: Option<Arc<[Arc<str>]>>,
108    /// Replace the maximum generic ratio threshold.
109    pub max_generic_ratio: Option<f64>,
110    /// Replace the minimum generic count threshold.
111    pub min_generic_count: Option<usize>,
112}
113
114static GENERIC_NAMES_ARC: LazyLock<Arc<[Arc<str>]>> = LazyLock::new(|| {
115    DEFAULT_GENERIC_NAMES
116        .iter()
117        .map(|s| Arc::from(*s))
118        .collect()
119});
120
121fn default_generic_names() -> Arc<[Arc<str>]> {
122    Arc::clone(&GENERIC_NAMES_ARC)
123}
124
125type ArcStrSlice = Arc<[Arc<str>]>;
126
127fn deserialize_option_arc_str_slice<'de, D: serde::Deserializer<'de>>(
128    deserializer: D,
129) -> Result<Option<ArcStrSlice>, D::Error> {
130    let opt: Option<Vec<String>> = Option::deserialize(deserializer)?;
131    Ok(opt.map(|v| v.into_iter().map(Arc::from).collect()))
132}
133
134fn default_max_generic_ratio() -> f64 {
135    0.3
136}
137
138fn default_min_generic_count() -> usize {
139    2
140}
141
142/// Path-specific overrides for a pattern check. `None` inherits from base config.
143#[derive(Debug, Deserialize, Default, Clone)]
144#[serde(deny_unknown_fields)]
145pub struct PatternOverride {
146    /// Replace the enabled state.
147    pub enabled: Option<bool>,
148    /// Replace the pattern list. Empty slice inherits from base.
149    #[serde(default, deserialize_with = "deserialize_arc_str_slice")]
150    pub patterns: Arc<[Arc<str>]>,
151}
152
153/// Deserialized `.pedant.toml` file with all check settings.
154#[derive(Debug, Deserialize, Default)]
155#[serde(deny_unknown_fields)]
156pub struct ConfigFile {
157    /// Security gate rules configuration.
158    #[serde(default)]
159    pub gate: GateConfig,
160    /// Depth limit for nesting checks (default: 3).
161    #[serde(default = "default_max_depth")]
162    pub max_depth: usize,
163    /// Branch count that triggers `else-chain` (default: 3).
164    #[serde(default = "default_else_chain_threshold")]
165    pub else_chain_threshold: usize,
166    /// Maximum parameter count before `high-param-count` fires (default: 5).
167    #[serde(default = "default_max_params")]
168    pub max_params: usize,
169    /// Banned attribute patterns (e.g., `allow(dead_code)`).
170    #[serde(default)]
171    pub forbid_attributes: PatternCheck,
172    /// Banned type patterns (e.g., `Arc<String>`).
173    #[serde(default)]
174    pub forbid_types: PatternCheck,
175    /// Banned method call patterns (e.g., `.unwrap()`).
176    #[serde(default)]
177    pub forbid_calls: PatternCheck,
178    /// Banned macro patterns (e.g., `panic!`).
179    #[serde(default)]
180    pub forbid_macros: PatternCheck,
181    /// Thresholds for the generic-naming check.
182    #[serde(default)]
183    pub check_naming: NamingCheck,
184    /// Flag `if` inside `if`.
185    #[serde(default = "default_true")]
186    pub check_nested_if: bool,
187    /// Flag `if` inside `match` arm.
188    #[serde(default = "default_true")]
189    pub check_if_in_match: bool,
190    /// Flag `match` inside `match`.
191    #[serde(default = "default_true")]
192    pub check_nested_match: bool,
193    /// Flag `match` inside `if` branch.
194    #[serde(default = "default_true")]
195    pub check_match_in_if: bool,
196    /// Flag long `if/else if` chains.
197    #[serde(default = "default_true")]
198    pub check_else_chain: bool,
199    /// Flag any use of the `else` keyword.
200    #[serde(default)]
201    pub forbid_else: bool,
202    /// Flag any `unsafe` block.
203    #[serde(default = "default_true")]
204    pub forbid_unsafe: bool,
205    /// Flag dynamic dispatch in return types.
206    #[serde(default)]
207    pub check_dyn_return: bool,
208    /// Flag dynamic dispatch in function parameters.
209    #[serde(default)]
210    pub check_dyn_param: bool,
211    /// Flag `Vec<Box<dyn T>>` anywhere.
212    #[serde(default)]
213    pub check_vec_box_dyn: bool,
214    /// Flag dynamic dispatch in struct fields.
215    #[serde(default)]
216    pub check_dyn_field: bool,
217    /// Flag `.clone()` inside loop bodies.
218    #[serde(default)]
219    pub check_clone_in_loop: bool,
220    /// Flag `HashMap`/`HashSet` with default SipHash hasher.
221    #[serde(default)]
222    pub check_default_hasher: bool,
223    /// Flag disconnected type groups in a single file.
224    #[serde(default)]
225    pub check_mixed_concerns: bool,
226    /// Flag `#[cfg(test)] mod` blocks embedded in source files.
227    #[serde(default)]
228    pub check_inline_tests: bool,
229    /// Flag `let _ = expr` that discards a Result.
230    #[serde(default)]
231    pub check_let_underscore_result: bool,
232    /// Flag functions with too many parameters.
233    #[serde(default)]
234    pub check_high_param_count: bool,
235    /// Per-path configuration overrides keyed by glob pattern.
236    #[serde(default)]
237    pub overrides: BTreeMap<Box<str>, PathOverride>,
238}
239
240/// Per-path overrides (e.g., for `tests/**`). `None` inherits from base config.
241#[derive(Debug, Deserialize, Default)]
242#[serde(deny_unknown_fields)]
243pub struct PathOverride {
244    /// `Some(false)` disables all checks for matched paths.
245    pub enabled: Option<bool>,
246    /// Replace nesting depth limit.
247    pub max_depth: Option<usize>,
248    /// Replace maximum parameter count.
249    pub max_params: Option<usize>,
250    /// Replace forbidden attribute patterns.
251    pub forbid_attributes: Option<PatternOverride>,
252    /// Replace forbidden type patterns.
253    pub forbid_types: Option<PatternOverride>,
254    /// Replace forbidden call patterns.
255    pub forbid_calls: Option<PatternOverride>,
256    /// Replace forbidden macro patterns.
257    pub forbid_macros: Option<PatternOverride>,
258    /// Replace generic naming thresholds.
259    pub check_naming: Option<NamingOverride>,
260    /// Replace nested-if check state.
261    pub check_nested_if: Option<bool>,
262    /// Replace if-in-match check state.
263    pub check_if_in_match: Option<bool>,
264    /// Replace nested-match check state.
265    pub check_nested_match: Option<bool>,
266    /// Replace match-in-if check state.
267    pub check_match_in_if: Option<bool>,
268    /// Replace else-chain check state.
269    pub check_else_chain: Option<bool>,
270    /// Replace `else` keyword ban state.
271    pub forbid_else: Option<bool>,
272    /// Replace `unsafe` block ban state.
273    pub forbid_unsafe: Option<bool>,
274    /// Replace dyn-return check state.
275    pub check_dyn_return: Option<bool>,
276    /// Replace dyn-param check state.
277    pub check_dyn_param: Option<bool>,
278    /// Replace `Vec<Box<dyn T>>` check state.
279    pub check_vec_box_dyn: Option<bool>,
280    /// Replace dyn-field check state.
281    pub check_dyn_field: Option<bool>,
282    /// Replace clone-in-loop check state.
283    pub check_clone_in_loop: Option<bool>,
284    /// Replace default-hasher check state.
285    pub check_default_hasher: Option<bool>,
286    /// Replace mixed-concerns check state.
287    pub check_mixed_concerns: Option<bool>,
288    /// Replace inline-tests check state.
289    pub check_inline_tests: Option<bool>,
290    /// Replace let-underscore-result check state.
291    pub check_let_underscore_result: Option<bool>,
292    /// Replace high-param-count check state.
293    pub check_high_param_count: Option<bool>,
294}
295
296fn default_max_depth() -> usize {
297    3
298}
299
300fn default_else_chain_threshold() -> usize {
301    3
302}
303
304fn default_max_params() -> usize {
305    5
306}
307
308fn default_true() -> bool {
309    true
310}
311
312/// Find the most specific `[overrides]` entry whose glob matches `file_path`.
313///
314/// When multiple patterns match, the longest pattern wins. If two patterns
315/// have the same length, lexicographic (sorted) order breaks the tie.
316/// This makes override precedence deterministic and independent of
317/// declaration order in the config file.
318pub fn check_path_override<'a>(
319    file_path: &str,
320    config: &'a ConfigFile,
321) -> Option<&'a PathOverride> {
322    config
323        .overrides
324        .iter()
325        .filter(|(pattern, _)| matches_glob(pattern, file_path))
326        .max_by_key(|(pattern, _)| pattern.len())
327        .map(|(_, override_config)| override_config)
328}
329
330/// Single source of truth for boolean check fields.
331///
332/// Each entry: `"doc", field_name, default_value;`
333///
334/// Adding a new boolean check requires:
335/// 1. Add one line here
336/// 2. Add the field to `ConfigFile` (bool) and `PathOverride` (Option<bool>)
337///
338/// The macro generates `CheckConfig` fields + Default + from_config_file +
339/// merge_bool_overrides. A compile-time assertion (`assert_bool_fields_in_sync`)
340/// catches missing fields in `ConfigFile` or `PathOverride`.
341///
342/// Non-boolean fields (max_depth, forbid_*, check_naming, etc.) stay hand-written.
343macro_rules! for_each_bool_check {
344    ($callback:ident!) => {
345        $callback! {
346            "Flag `if` inside `if`.", check_nested_if, true;
347            "Flag `if` inside `match` arm.", check_if_in_match, true;
348            "Flag `match` inside `match`.", check_nested_match, true;
349            "Flag `match` inside `if` branch.", check_match_in_if, true;
350            "Flag long `if/else if` chains.", check_else_chain, true;
351            "Flag any use of the `else` keyword.", forbid_else, false;
352            "Flag any `unsafe` block.", forbid_unsafe, true;
353            "Flag dynamic dispatch in return types.", check_dyn_return, false;
354            "Flag dynamic dispatch in function parameters.", check_dyn_param, false;
355            "Flag `Vec<Box<dyn T>>`.", check_vec_box_dyn, false;
356            "Flag dynamic dispatch in struct fields.", check_dyn_field, false;
357            "Flag `.clone()` inside loop bodies.", check_clone_in_loop, false;
358            "Flag `HashMap`/`HashSet` with default hasher.", check_default_hasher, false;
359            "Flag disconnected type groups in a single file.", check_mixed_concerns, false;
360            "Flag `#[cfg(test)] mod` blocks in source files.", check_inline_tests, false;
361            "Flag `let _ = expr` that discards a Result.", check_let_underscore_result, false;
362            "Flag functions with too many parameters.", check_high_param_count, false;
363        }
364    };
365}
366
367/// Generates `CheckConfig` struct (boolean fields + non-boolean fields),
368/// `Default`, `from_config_file`, and `merge_bool_overrides`.
369macro_rules! impl_check_config {
370    ($($doc:literal, $field:ident, $default:expr;)*) => {
371        /// Configuration controlling which checks are enabled and their thresholds.
372        #[derive(Debug, Clone)]
373        pub struct CheckConfig {
374            /// Maximum allowed nesting depth.
375            pub max_depth: usize,
376            /// Minimum branches to trigger `else-chain`.
377            pub else_chain_threshold: usize,
378            /// Maximum parameter count before `high-param-count` fires.
379            pub max_params: usize,
380            /// Banned attribute patterns.
381            pub forbid_attributes: PatternCheck,
382            /// Banned type patterns.
383            pub forbid_types: PatternCheck,
384            /// Banned method call patterns.
385            pub forbid_calls: PatternCheck,
386            /// Banned macro patterns.
387            pub forbid_macros: PatternCheck,
388            /// Generic naming check configuration.
389            pub check_naming: NamingCheck,
390            $(
391                #[doc = $doc]
392                pub $field: bool,
393            )*
394        }
395
396        impl Default for CheckConfig {
397            fn default() -> Self {
398                Self {
399                    max_depth: default_max_depth(),
400                    else_chain_threshold: default_else_chain_threshold(),
401                    max_params: default_max_params(),
402                    forbid_attributes: PatternCheck::default(),
403                    forbid_types: PatternCheck::default(),
404                    forbid_calls: PatternCheck::default(),
405                    forbid_macros: PatternCheck::default(),
406                    check_naming: NamingCheck::default(),
407                    $( $field: $default, )*
408                }
409            }
410        }
411
412        impl CheckConfig {
413            /// Build from a [`ConfigFile`], copying all fields.
414            pub fn from_config_file(fc: &ConfigFile) -> Self {
415                Self {
416                    max_depth: fc.max_depth,
417                    else_chain_threshold: fc.else_chain_threshold,
418                    max_params: fc.max_params,
419                    forbid_attributes: fc.forbid_attributes.clone(),
420                    forbid_types: fc.forbid_types.clone(),
421                    forbid_calls: fc.forbid_calls.clone(),
422                    forbid_macros: fc.forbid_macros.clone(),
423                    check_naming: fc.check_naming.clone(),
424                    $( $field: fc.$field, )*
425                }
426            }
427
428            /// Apply `Option<bool>` overrides from a [`PathOverride`].
429            pub fn merge_bool_overrides(&mut self, ovr: &PathOverride) {
430                $(
431                    if let Some(v) = ovr.$field {
432                        self.$field = v;
433                    }
434                )*
435            }
436        }
437    };
438}
439
440for_each_bool_check!(impl_check_config!);
441
442/// Compile-time assertion: every boolean check field in `for_each_bool_check!`
443/// must exist in `ConfigFile` (as `bool`) and `PathOverride` (as `Option<bool>`).
444/// Adding a field to the macro without updating these structs is a compile error.
445macro_rules! assert_bool_fields_in_sync {
446    ($($doc:literal, $field:ident, $default:expr;)*) => {
447        const _: () = {
448            // Access each field on both structs. If a field is missing
449            // from either, this block fails to compile.
450            const fn _check(cf: &ConfigFile, po: &PathOverride) {
451                $( let _ = (cf.$field, po.$field); )*
452            }
453        };
454    };
455}
456
457for_each_bool_check!(assert_bool_fields_in_sync!);
458
459impl CheckConfig {
460    /// Returns the effective config for a file path.
461    ///
462    /// Borrows `self` when no overrides match (zero clones).
463    /// Clones and mutates only when a path override applies.
464    /// Returns `None` when the override disables analysis for this path.
465    pub fn resolve_for_path<'a>(
466        &'a self,
467        file_path: &str,
468        file_config: Option<&ConfigFile>,
469    ) -> Option<Cow<'a, Self>> {
470        let Some(fc) = file_config else {
471            return Some(Cow::Borrowed(self));
472        };
473
474        let Some(override_cfg) = check_path_override(file_path, fc) else {
475            return Some(Cow::Borrowed(self));
476        };
477
478        if override_cfg.enabled == Some(false) {
479            return None;
480        }
481
482        let mut config = self.clone();
483        if let Some(max_depth) = override_cfg.max_depth {
484            config.max_depth = max_depth;
485        }
486        if let Some(max_params) = override_cfg.max_params {
487            config.max_params = max_params;
488        }
489
490        config.merge_bool_overrides(override_cfg);
491
492        macro_rules! apply {
493            ($field:ident) => {
494                if let Some(ref ovr) = override_cfg.$field {
495                    config.$field.apply_override(ovr);
496                }
497            };
498        }
499        apply!(forbid_attributes);
500        apply!(forbid_types);
501        apply!(forbid_calls);
502        apply!(forbid_macros);
503        apply!(check_naming);
504
505        Some(Cow::Owned(config))
506    }
507}
508
509/// Failure modes when loading `.pedant.toml`.
510#[derive(Debug, thiserror::Error)]
511pub enum ConfigError {
512    /// Disk I/O failure reading the config file.
513    #[error("failed to read config file: {0}")]
514    Read(#[from] std::io::Error),
515    /// TOML syntax or schema error in the config file.
516    #[error("failed to parse config file: {0}")]
517    Parse(#[from] toml::de::Error),
518}
519
520/// Read and deserialize a `.pedant.toml` from the given path.
521pub fn load_config_file(path: &Path) -> Result<ConfigFile, ConfigError> {
522    let content = fs::read_to_string(path)?;
523    Ok(toml::from_str(&content)?)
524}
525
526/// Search `.pedant.toml` in the project root, then `$XDG_CONFIG_HOME/pedant/config.toml`.
527pub fn find_config_file() -> Result<Option<std::path::PathBuf>, ConfigError> {
528    let project_config = find_project_config_file()?;
529    Ok(project_config.or_else(find_global_config_file))
530}
531
532fn find_project_config_file() -> Result<Option<std::path::PathBuf>, ConfigError> {
533    let config_path = std::env::current_dir()?.join(".pedant.toml");
534    Ok(config_path.exists().then_some(config_path))
535}
536
537fn find_global_config_file() -> Option<std::path::PathBuf> {
538    let config_dir = std::env::var_os("XDG_CONFIG_HOME")
539        .map(std::path::PathBuf::from)
540        .or_else(|| {
541            std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
542        })?;
543    let config_path = config_dir.join("pedant").join("config.toml");
544    config_path.exists().then_some(config_path)
545}
546
547/// Per-rule override from the `[gate]` TOML section.
548#[derive(Debug)]
549pub enum GateRuleOverride {
550    /// Suppresses the rule entirely.
551    Disabled,
552    /// Changes the rule's effective severity.
553    Severity(crate::gate::GateSeverity),
554}
555
556/// Deserialized `[gate]` section of `.pedant.toml`.
557///
558/// Keys are either `enabled` (master switch) or rule names mapped to
559/// `false` (disabled) or a severity string (`"deny"`, `"warn"`, `"info"`).
560#[derive(Debug)]
561pub struct GateConfig {
562    /// Master switch; `false` disables all gate rules.
563    pub enabled: bool,
564    /// Per-rule overrides keyed by rule name.
565    pub overrides: BTreeMap<Box<str>, GateRuleOverride>,
566}
567
568impl Default for GateConfig {
569    fn default() -> Self {
570        Self {
571            enabled: true,
572            overrides: BTreeMap::new(),
573        }
574    }
575}
576
577impl<'de> Deserialize<'de> for GateConfig {
578    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
579    where
580        D: serde::Deserializer<'de>,
581    {
582        use serde::de::Error;
583
584        #[derive(Deserialize)]
585        #[serde(untagged)]
586        enum GateTomlValue {
587            Bool(bool),
588            String(String),
589        }
590
591        let raw: BTreeMap<Box<str>, GateTomlValue> = BTreeMap::deserialize(deserializer)?;
592        let mut enabled = true;
593        let mut overrides = BTreeMap::new();
594
595        for (key, value) in raw {
596            match (&*key, value) {
597                ("enabled", GateTomlValue::Bool(b)) => enabled = b,
598                ("enabled", GateTomlValue::String(_)) => {
599                    return Err(D::Error::custom("'enabled' must be a boolean"));
600                }
601                (_, _) if !is_known_gate_rule(&key) => {
602                    return Err(D::Error::custom(format!("unknown gate rule '{key}'")));
603                }
604                (_, GateTomlValue::Bool(false)) => {
605                    overrides.insert(key, GateRuleOverride::Disabled);
606                }
607                (_, GateTomlValue::Bool(true)) => {} // true = use default, no override
608                (_, GateTomlValue::String(s)) => {
609                    let severity = parse_gate_severity(&s).ok_or_else(|| {
610                        D::Error::custom(format!(
611                            "invalid gate severity '{s}': expected \"deny\", \"warn\", or \"info\""
612                        ))
613                    })?;
614                    overrides.insert(key, GateRuleOverride::Severity(severity));
615                }
616            }
617        }
618
619        Ok(GateConfig { enabled, overrides })
620    }
621}
622
623fn is_known_gate_rule(rule_name: &str) -> bool {
624    crate::gate::all_gate_rules()
625        .iter()
626        .any(|rule| rule.name == rule_name)
627}
628
629fn parse_gate_severity(s: &str) -> Option<crate::gate::GateSeverity> {
630    use crate::gate::GateSeverity;
631    match s {
632        "deny" => Some(GateSeverity::Deny),
633        "warn" => Some(GateSeverity::Warn),
634        "info" => Some(GateSeverity::Info),
635        _ => None,
636    }
637}