diff --git a/README.md b/README.md index 8bccd040c1f9..5ee7c721bf54 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,17 @@ disallowed-names = ["bar", ".."] # -> ["bar", "foo", "baz", "quux"] > **Note** > -> `clippy.toml` or `.clippy.toml` cannot be used to allow/deny lints. +> To configure lint levels in `clippy.toml`, use the `[lints.clippy]` table, +> which supports the same format as `Cargo.toml`. For example: +> +> ```toml +> [lints.clippy] +> needless_return = "allow" +> single_match = { level = "warn", priority = 5 } +> +> [lints.rust] +> dead_code = "allow" +> ``` To deactivate the “for further information visit *lint-link*” message you can define the `CLIPPY_DISABLE_DOCS_LINKS` environment variable. diff --git a/book/src/configuration.md b/book/src/configuration.md index b270c11ab397..ce84c857f864 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -100,6 +100,25 @@ enum_glob_use = "deny" For more details and options, refer to the Cargo documentation. +#### Lints Section in `clippy.toml` + +Lint levels can also be configured directly in `clippy.toml` using a `[lints]` +table with the same format as `Cargo.toml`. This allows you to keep lint +configuration separate from dependency management. + +```toml +[lints.clippy] +needless_return = "allow" +single_match = { level = "warn", priority = 5 } + +[lints.rust] +dead_code = "allow" +``` + +The supported lint level values are `"allow"`, `"warn"`, `"deny"`, and +`"forbid"`. Each entry can either be a plain string (the level) or a table +with `level` and an optional `priority` field (an integer, defaulting to 0). + ### Specifying the minimum supported Rust version Projects that intend to support old versions of Rust can disable lints pertaining to newer features by specifying the diff --git a/clippy_config/src/conf.rs b/clippy_config/src/conf.rs index 41099f94b044..c777b3184296 100644 --- a/clippy_config/src/conf.rs +++ b/clippy_config/src/conf.rs @@ -13,13 +13,60 @@ use rustc_span::edit_distance::edit_distance; use rustc_span::{BytePos, Pos, SourceFile, Span, SyntaxContext}; use serde::de::{IgnoredAny, IntoDeserializer, MapAccess, Visitor}; use serde::{Deserialize, Deserializer, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt::{Debug, Display, Formatter}; use std::ops::Range; use std::path::PathBuf; use std::str::FromStr; use std::sync::OnceLock; use std::{cmp, env, fmt, fs, io}; +use toml::Spanned; + +#[derive(Deserialize, Serialize, Debug)] +pub struct LintConfigTable { + level: String, + priority: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +pub enum LintConfig { + Level(String), + Table(LintConfigTable), +} + +impl LintConfig { + pub fn level(&self) -> &str { + match self { + LintConfig::Level(level) => level, + LintConfig::Table(table) => &table.level, + } + } + + pub fn priority(&self) -> i64 { + match self { + LintConfig::Level(_) => 0, + LintConfig::Table(table) => table.priority.unwrap_or(0), + } + } +} + +type LintTable = BTreeMap, Spanned>; + +#[derive(Deserialize, Debug, Default)] +pub struct Lints { + #[serde(default)] + pub clippy: LintTable, +} + +// Lightweight representation of the `[lints]` table for eager reads that don't +// require span information. This allows the driver to merge CLI flags with +// clippy.toml settings before the rustc `Session` exists. +#[derive(Deserialize, Debug, Default)] +pub struct LintsPlain { + #[serde(default)] + pub clippy: BTreeMap, +} #[rustfmt::skip] const DEFAULT_DOC_VALID_IDENTS: &[&str] = &[ @@ -86,6 +133,7 @@ struct TryConf { value_spans: HashMap>, errors: Vec, warnings: Vec, + lints: Option, } impl TryConf { @@ -95,6 +143,7 @@ impl TryConf { value_spans: HashMap::default(), errors: vec![ConfError::from_toml(file, error)], warnings: vec![], + lints: None, } } } @@ -249,7 +298,7 @@ macro_rules! define_Conf { #[derive(Deserialize)] #[serde(field_identifier, rename_all = "kebab-case")] #[expect(non_camel_case_types)] - enum Field { $($name,)* third_party, } + enum Field { $($name,)* lints, third_party, } struct ConfVisitor<'a>(&'a SourceFile); @@ -264,6 +313,7 @@ macro_rules! define_Conf { let mut value_spans = HashMap::new(); let mut errors = Vec::new(); let mut warnings = Vec::new(); + let mut lints = None; // Declare a local variable for each field available to a configuration file. $(let mut $name = None;)* @@ -302,12 +352,16 @@ macro_rules! define_Conf { None => $new_conf = $name.clone(), })? })* + Field::lints => { + let lints_value = map.next_value::>()?; + lints = Some(lints_value.into_inner()); + } // ignore contents of the third_party key Field::third_party => drop(map.next_value::()) } } let conf = Conf { $($name: $name.unwrap_or_else(defaults::$name),)* }; - Ok(TryConf { conf, value_spans, errors, warnings }) + Ok(TryConf { conf, value_spans, errors, warnings, lints }) } } @@ -1036,6 +1090,8 @@ fn extend_vec_if_indicator_present(vec: &mut Vec, default: &[&str]) { } } +static CLIPPY_TOML_LINTS: OnceLock> = OnceLock::new(); + impl Conf { pub fn read(sess: &Session, path: &io::Result<(Option, Vec)>) -> &'static Conf { static CONF: OnceLock = OnceLock::new(); @@ -1060,6 +1116,7 @@ impl Conf { value_spans: _, errors, warnings, + lints, } = match path { Ok((Some(path), _)) => match sess.source_map().load_file(path) { Ok(file) => deserialize(&file), @@ -1073,6 +1130,9 @@ impl Conf { conf.msrv.read_cargo(sess); + // Store the lints configuration for use in the driver + CLIPPY_TOML_LINTS.set(lints).ok(); + // all conf errors are non-fatal, we just use the default conf in case of error for error in errors { let mut diag = sess.dcx().struct_span_err( @@ -1262,3 +1322,25 @@ mod tests { ); } } + +pub fn clippy_toml_lints() -> &'static Option { + CLIPPY_TOML_LINTS.get().unwrap_or(&None) +} + +/// Reads only the `[lints]` table from the configuration file without requiring a `Session`. +/// Returns `None` if no config file was found or if the file does not contain a `[lints]` table. +pub fn read_lints_from_conf_path(path: &io::Result<(Option, Vec)>) -> Option { + let (Some(conf_path), _) = path.as_ref().ok()? else { + return None; + }; + let Ok(src) = fs::read_to_string(conf_path) else { + return None; + }; + let Ok(doc) = src.parse::() else { + return None; + }; + let Some(lints_val) = doc.get("lints") else { + return None; + }; + lints_val.clone().try_into().ok() +} diff --git a/clippy_config/src/lib.rs b/clippy_config/src/lib.rs index 0a891e75eb3e..62f999e39e70 100644 --- a/clippy_config/src/lib.rs +++ b/clippy_config/src/lib.rs @@ -19,5 +19,8 @@ mod conf; mod metadata; pub mod types; -pub use conf::{Conf, get_configuration_metadata, lookup_conf_file, sanitize_explanation}; +pub use conf::{ + Conf, Lints, LintsPlain, clippy_toml_lints, get_configuration_metadata, lookup_conf_file, + read_lints_from_conf_path, sanitize_explanation, +}; pub use metadata::ClippyConfiguration; diff --git a/src/clippy_lint_rules.rs b/src/clippy_lint_rules.rs new file mode 100644 index 000000000000..d684e63d0e58 --- /dev/null +++ b/src/clippy_lint_rules.rs @@ -0,0 +1,355 @@ +use rustc_data_structures::fx::FxHashMap; + +fn level_from_flag(flag: &str) -> Option<&'static str> { + match flag { + "-A" | "--allow" => Some("allow"), + "-W" | "--warn" => Some("warn"), + "-D" | "--deny" => Some("deny"), + "-F" | "--forbid" => Some("forbid"), + _ => None, + } +} + +fn flag_from_level(level: &str) -> Option<&'static str> { + match level { + "allow" => Some("-A"), + "warn" => Some("-W"), + "deny" => Some("-D"), + "forbid" => Some("-F"), + _ => None, + } +} + +fn read_clippy_toml_overrides() -> Vec<(String, String, i64)> { + if let Ok(conf_path) = clippy_config::lookup_conf_file() { + if let Some(lints) = clippy_config::read_lints_from_conf_path(&Ok(conf_path)) { + return lints + .clippy + .into_iter() + .map(|(n, c)| (format!("clippy::{}", n), c.level().to_string(), c.priority())) + .collect(); + } + } + Vec::new() +} + +#[cfg(test)] +fn find_conf_file_from(start_dir: &std::path::Path) -> Option { + const CONFIG_FILE_NAMES: [&str; 2] = [".clippy.toml", "clippy.toml"]; + let mut current = start_dir.canonicalize().ok()?; + loop { + for name in &CONFIG_FILE_NAMES { + let candidate = current.join(name); + if let Ok(md) = std::fs::metadata(&candidate) { + if md.is_file() { + return Some(candidate); + } + } + } + if !current.pop() { + return None; + } + } +} + +#[cfg(test)] +fn read_clippy_toml_overrides_from(start_dir: &std::path::Path) -> Vec<(String, String, i64)> { + if let Some(conf) = find_conf_file_from(start_dir) { + if let Some(lints) = clippy_config::read_lints_from_conf_path(&Ok((Some(conf), vec![]))) { + return lints + .clippy + .into_iter() + .map(|(n, c)| (format!("clippy::{}", n), c.level().to_string(), c.priority())) + .collect(); + } + } + Vec::new() +} + +fn parse_existing_lint_args(args: &[String]) -> FxHashMap { + let mut m = FxHashMap::default(); + let mut i = 0; + let mut push = |lvl: &str, name: String| { + if name.starts_with("clippy::") { + m.insert(name, (lvl.to_string(), 0)); + } + }; + while i < args.len() { + let a = &args[i]; + if let Some((f, v)) = a.split_once('=') { + if let Some(l) = level_from_flag(f) { + push(l, v.to_string()); + i += 1; + continue; + } + } + if a.len() > 2 { + let (h, t) = a.split_at(2); + if let Some(l) = level_from_flag(h) { + if t.starts_with("clippy::") { + push(l, t.to_string()); + i += 1; + continue; + } + } + } + if let Some(l) = level_from_flag(a) { + if let Some(n) = args.get(i + 1) { + if n.starts_with("clippy::") { + push(l, n.clone()); + i += 2; + continue; + } + } + } + i += 1; + } + m +} + +#[allow(rustc::potential_query_instability)] +fn build_merged_clippy_lint_args(existing_args: &[String], overrides: &[(String, String, i64)]) -> Vec { + let mut all: Vec<(String, String, i64)> = parse_existing_lint_args(existing_args) + .into_iter() + .map(|(n, (l, p))| (n, l, p)) + .collect(); + + for (name, level, prio) in overrides { + all.retain(|(n, _, _)| n != name); + all.push((name.clone(), level.clone(), *prio)); + } + + all.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0))); + let mut out = Vec::with_capacity(all.len() * 2); + for (name, lvl, _) in all { + let Some(flag) = flag_from_level(lvl.as_str()) else { + continue; + }; + out.push(flag.to_string()); + out.push(name); + } + out +} + +pub fn apply_merged_clippy_lints(args: Vec) -> Vec { + let overrides = read_clippy_toml_overrides(); + let merged = build_merged_clippy_lint_args(&args, &overrides); + if merged.is_empty() { + return args; + } + let mut out = Vec::with_capacity(args.len() + merged.len()); + let mut i = 0; + let is_flag = |s: &str| level_from_flag(s).is_some(); + while i < args.len() { + let a = &args[i]; + if let Some((f, v)) = a.split_once('=') { + if is_flag(f) && v.starts_with("clippy::") { + i += 1; + continue; + } + } + if a.len() > 2 { + let (h, t) = a.split_at(2); + if is_flag(h) && t.starts_with("clippy::") { + i += 1; + continue; + } + } + if is_flag(a) { + if let Some(n) = args.get(i + 1) { + if n.starts_with("clippy::") { + i += 2; + continue; + } + } + } + out.push(a.clone()); + i += 1; + } + out.extend(merged); + out +} + +#[cfg(test)] +pub fn apply_merged_clippy_lints_from_path(args: Vec, start_dir: &std::path::Path) -> Vec { + let overrides = read_clippy_toml_overrides_from(start_dir); + let merged = build_merged_clippy_lint_args(&args, &overrides); + if merged.is_empty() { + return args; + } + let mut out = Vec::with_capacity(args.len() + merged.len()); + let mut i = 0; + let is_flag = |s: &str| level_from_flag(s).is_some(); + while i < args.len() { + let a = &args[i]; + if let Some((f, v)) = a.split_once('=') { + if is_flag(f) && v.starts_with("clippy::") { + i += 1; + continue; + } + } + if a.len() > 2 { + let (h, t) = a.split_at(2); + if is_flag(h) && t.starts_with("clippy::") { + i += 1; + continue; + } + } + if is_flag(a) { + if let Some(n) = args.get(i + 1) { + if n.starts_with("clippy::") { + i += 2; + continue; + } + } + } + out.push(a.clone()); + i += 1; + } + out.extend(merged); + out +} + +#[cfg(test)] +mod tests { + use super::{ + apply_merged_clippy_lints_from_path, build_merged_clippy_lint_args, + parse_existing_lint_args, + }; + use std::fs; + use std::path::Path; + + #[test] + fn parse_examples() { + let args = vec![ + "--allow=clippy::pedantic", + "-Wclippy::redundant_clone", + "-D", + "clippy::unwrap_used", + "--forbid", + "clippy::dbg_macro", + "--warn=unused_variables", // non-clippy → ignored + ] + .into_iter() + .map(String::from) + .collect::>(); + + let m = parse_existing_lint_args(&args); + assert_eq!(m.get("clippy::pedantic").map(|v| v.0.as_str()), Some("allow")); + assert_eq!(m.get("clippy::redundant_clone").map(|v| v.0.as_str()), Some("warn")); + assert_eq!(m.get("clippy::unwrap_used").map(|v| v.0.as_str()), Some("deny")); + assert_eq!(m.get("clippy::dbg_macro").map(|v| v.0.as_str()), Some("forbid")); + assert!(!m.contains_key("unused_variables")); + } + + #[test] + fn parse_ignores_unknown_and_non_clippy() { + let args = vec![ + "--force-warn=clippy::all", // unknown level flag → ignored by parser + "--allow", + "not-a-lint", // missing or non-clippy values → ignored + "--warn=dead_code", // non-clippy lint → ignored + ] + .into_iter() + .map(String::from) + .collect::>(); + + let m = parse_existing_lint_args(&args); + assert!(m.is_empty()); + } + + #[test] + fn build_merge_overrides_and_order() { + // Existing args include various forms + let existing = vec!["-W", "clippy::foo", "--deny=clippy::bar", "-Aclippy::baz"] + .into_iter() + .map(String::from) + .collect::>(); + + // Overrides replace foo → deny (prio 0) and add new → warn (prio 5) + let overrides = vec![ + ("clippy::foo".to_string(), "deny".to_string(), 0), + ("clippy::new".to_string(), "warn".to_string(), 5), + ]; + + let merged = build_merged_clippy_lint_args(&existing, &overrides); + + // Convert flat args into pairs for assertions + let pairs: Vec<(&str, &str)> = merged + .chunks(2) + .filter_map(|c| { + if c.len() == 2 { + Some((c[0].as_str(), c[1].as_str())) + } else { + None + } + }) + .collect(); + + // Expect replaced foo now as deny + assert!(pairs.contains(&("-D", "clippy::foo"))); + + // Existing bar deny retained, baz allow retained, and new warn added + assert!(pairs.contains(&("-D", "clippy::bar"))); + assert!(pairs.contains(&("-A", "clippy::baz"))); + assert!(pairs.contains(&("-W", "clippy::new"))); + + // Order: lowest priority first → foo (0) should appear before new (5) + let idx_foo = pairs + .iter() + .position(|p| p == &("-D", "clippy::foo")) + .expect("foo present"); + let idx_new = pairs + .iter() + .position(|p| p == &("-W", "clippy::new")) + .expect("new present"); + assert!(idx_foo < idx_new); + } + + #[test] + fn merge_from_clippy_toml() { + // Create a temporary config directory with a clippy.toml + let base = std::env::temp_dir(); + let unique = format!( + "clippy_merge_test_{}_{}", + std::process::id(), + std::time::SystemTime::now().elapsed().unwrap().as_nanos() + ); + let dir = base.join(unique); + fs::create_dir_all(&dir).unwrap(); + + let toml = r#" +[lints.clippy] +foo = "deny" +bar = { level = "warn", priority = 5 } +"#; + fs::write(dir.join("clippy.toml"), toml).unwrap(); + + // Existing args include an allow on foo which should be overridden by TOML to deny + let existing = vec!["-A", "clippy::foo", "-Dclippy::baz"] + .into_iter() + .map(String::from) + .collect::>(); + + let merged = apply_merged_clippy_lints_from_path(existing, Path::new(&dir)); + let pairs: Vec<(&str, &str)> = merged + .chunks(2) + .filter_map(|c| { + if c.len() == 2 { + Some((c[0].as_str(), c[1].as_str())) + } else { + None + } + }) + .collect(); + + // foo comes from TOML as deny, bar added as warn, baz retained as deny + assert!(pairs.contains(&("-D", "clippy::foo"))); + assert!(pairs.contains(&("-W", "clippy::bar"))); + assert!(pairs.contains(&("-D", "clippy::baz"))); + + // Cleanup files + let _ = fs::remove_file(dir.join("clippy.toml")); + let _ = fs::remove_dir(&dir); + } +} diff --git a/src/driver.rs b/src/driver.rs index 7b7aac09643a..9ad69d939722 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -6,6 +6,7 @@ // FIXME: switch to something more ergonomic here, once available. // (Currently there is no way to opt into sysroot crates without `extern crate`.) +extern crate rustc_data_structures; extern crate rustc_driver; extern crate rustc_interface; extern crate rustc_session; @@ -33,6 +34,9 @@ use std::io::Write as _; use std::path::Path; use std::process::ExitCode; +mod clippy_lint_rules; +use clippy_lint_rules::apply_merged_clippy_lints; + /// If a command-line option matches `find_arg`, then apply the predicate `pred` on its value. If /// true, then return it. The parameter is assumed to be either `--arg=value` or `--arg value`. fn arg_value<'a>(args: &'a [String], find_arg: &str, pred: impl Fn(&str) -> bool) -> Option<&'a str> { @@ -132,6 +136,8 @@ struct ClippyCallbacks { clippy_args_var: Option, } +// clippy lints argv handling lives in `driver_lints.rs` + impl rustc_driver::Callbacks for ClippyCallbacks { #[expect(rustc::bad_opt_access, reason = "necessary in clippy driver to set `mir_opt_level`")] fn config(&mut self, config: &mut interface::Config) { @@ -307,6 +313,10 @@ fn main() -> ExitCode { let clippy_enabled = !cap_lints_allow && relevant_package && !info_query; if clippy_enabled { args.extend(clippy_args); + + // Merge and apply clippy.toml lints, preserving non-clippy flags + args = apply_merged_clippy_lints(args); + rustc_driver::run_compiler(&args, &mut ClippyCallbacks { clippy_args_var }); } else { rustc_driver::run_compiler(&args, &mut RustcCallbacks { clippy_args_var });