mod lint_registry;
pub use lint_registry::known_lint_names;
use lint_registry::{is_restriction_lint, map_lint_category, resolve_severity};
use crate::diagnostics::{Category, Diagnostic, Severity};
use crate::scanner::AnalysisPass;
use cargo_metadata::Message;
use cargo_metadata::diagnostic::DiagnosticLevel;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
const CLIPPY_TIMEOUT_SECS: u64 = 120;
const RESTRICTION_LINTS: &[&str] = &[
"clippy::unwrap_used",
"clippy::expect_used",
"clippy::panic",
"clippy::indexing_slicing",
"clippy::unwrap_in_result",
"clippy::panic_in_result_fn",
"clippy::exit",
"clippy::undocumented_unsafe_blocks",
"clippy::multiple_unsafe_ops_per_block",
"clippy::mem_forget",
"clippy::cognitive_complexity",
"clippy::dbg_macro",
"clippy::print_stdout",
"clippy::print_stderr",
"clippy::unimplemented",
"clippy::unreachable",
];
fn is_test_file(path: &Path) -> bool {
let s = path.to_string_lossy();
s.contains("/tests/") || s.starts_with("tests/")
}
fn is_line_in_test_module(content: &str, line: u32) -> bool {
for (i, text) in content.lines().enumerate() {
let trimmed = text.trim();
if trimmed == "#[cfg(test)]" || trimmed.starts_with("#[cfg(test)]") {
return line >= (i + 1) as u32;
}
}
false
}
pub struct ClippyPass;
impl AnalysisPass for ClippyPass {
fn name(&self) -> &'static str {
"clippy"
}
fn run(&self, project_root: &Path) -> Result<Vec<Diagnostic>, crate::error::PassError> {
if !is_clippy_available() {
return Err(crate::error::PassError::Skipped {
pass: "clippy".to_string(),
reason: "clippy is not installed — lint analysis disabled. \
Install with: rustup component add clippy"
.to_string(),
});
}
run_clippy(project_root).map_err(|message| crate::error::PassError::Failed {
pass: "clippy".to_string(),
message,
})
}
}
fn is_clippy_available() -> bool {
crate::process::is_cargo_subcommand_available("clippy")
}
fn build_clippy_warn_flags() -> Vec<String> {
let mut flags = Vec::new();
for group in [
"clippy::all",
"clippy::pedantic",
"clippy::nursery",
"clippy::cargo",
] {
flags.push("-W".to_string());
flags.push(group.to_string());
}
for lint in RESTRICTION_LINTS {
flags.push("-W".to_string());
flags.push((*lint).to_string());
}
flags
}
const CLIPPY_TEST_ALLOW_CONFIG: &str = "\
allow-unwrap-in-tests = true\n\
allow-expect-in-tests = true\n\
allow-indexing-slicing-in-tests = true\n\
allow-panic-in-tests = true\n\
allow-print-in-tests = true\n\
allow-dbg-in-tests = true\n\
allow-useless-vec-in-tests = true\n";
struct ClippyConfigGuard {
path: Option<PathBuf>,
}
impl ClippyConfigGuard {
fn new(dir: &Path) -> Self {
if dir.join("clippy.toml").exists() || dir.join(".clippy.toml").exists() {
return Self { path: None };
}
let config_path = dir.join("clippy.toml");
if std::fs::write(&config_path, CLIPPY_TEST_ALLOW_CONFIG).is_ok() {
Self {
path: Some(config_path),
}
} else {
Self { path: None }
}
}
}
impl Drop for ClippyConfigGuard {
fn drop(&mut self) {
if let Some(ref path) = self.path {
let _ = std::fs::remove_file(path);
}
}
}
fn process_compiler_message(
diag: &mut cargo_metadata::diagnostic::Diagnostic,
) -> Option<Diagnostic> {
let clippy_severity = match &diag.level {
DiagnosticLevel::Error | DiagnosticLevel::Ice => Severity::Error,
DiagnosticLevel::Warning => Severity::Warning,
_ => return None,
};
let is_ice = diag.level == DiagnosticLevel::Ice;
let rule = match diag.code.take() {
Some(code) => code.code,
None if clippy_severity == Severity::Error => if is_ice {
"compiler-ice"
} else {
"compiler-error"
}
.to_string(),
None => return None,
};
let primary_span = diag.spans.iter().find(|s| s.is_primary);
let (file_path, line, column) = primary_span.map_or_else(
|| (PathBuf::from("<unknown>"), None, None),
|span| {
(
PathBuf::from(&span.file_name),
Some(span.line_start as u32),
Some(span.column_start as u32),
)
},
);
let category = map_lint_category(&rule);
let severity = resolve_severity(&rule, clippy_severity);
let rendered = diag.rendered.take();
let help = std::mem::take(&mut diag.children)
.into_iter()
.find(|c| c.level == DiagnosticLevel::Help)
.map(|c| c.message)
.or(rendered);
Some(Diagnostic {
file_path,
rule,
category,
severity,
message: std::mem::take(&mut diag.message),
help,
line,
column,
fix: None,
})
}
fn build_stderr_fallback(stderr: std::process::ChildStderr) -> Option<Diagnostic> {
use std::io::Read;
const MAX_STDERR_BYTES: u64 = 4 * 1024; let mut stderr_output = String::new();
let _ = stderr
.take(MAX_STDERR_BYTES)
.read_to_string(&mut stderr_output);
if stderr_output.is_empty() {
return None;
}
let first_error = stderr_output
.lines()
.find(|l| l.starts_with("error"))
.unwrap_or("project failed to compile");
let truncated: String = if first_error.chars().count() > 200 {
let mut s: String = first_error.chars().take(200).collect();
s.push('\u{2026}');
s
} else {
first_error.to_string()
};
Some(Diagnostic {
file_path: PathBuf::from("Cargo.toml"),
rule: "compiler-error".to_string(),
category: Category::Correctness,
severity: Severity::Error,
message: truncated,
help: Some("Run `cargo build` to see the full error output".to_string()),
line: None,
column: None,
fix: None,
})
}
fn filter_test_and_binary_lints(diagnostics: &mut Vec<Diagnostic>, project_root: &Path) {
let mut file_cache: std::collections::HashMap<PathBuf, String> =
std::collections::HashMap::new();
diagnostics.retain(|d| {
if !is_restriction_lint(&d.rule) {
return true;
}
if is_test_file(&d.file_path) {
return false;
}
if let Some(line) = d.line {
let abs_path = if d.file_path.is_absolute() {
d.file_path.clone()
} else {
project_root.join(&d.file_path)
};
let content = file_cache
.entry(abs_path.clone())
.or_insert_with(|| std::fs::read_to_string(&abs_path).unwrap_or_default());
if is_line_in_test_module(content, line) {
return false;
}
}
true
});
if project_root.join("src/main.rs").exists() {
diagnostics.retain(|d| {
!matches!(
d.rule.as_str(),
"clippy::print_stdout" | "clippy::print_stderr"
)
});
}
}
fn run_clippy(project_root: &Path) -> Result<Vec<Diagnostic>, String> {
let manifest_path = project_root.join("Cargo.toml");
let warn_flags = build_clippy_warn_flags();
let _clippy_config_guard = ClippyConfigGuard::new(project_root);
let mut cmd = Command::new("cargo");
cmd.env("CARGO_TARGET_DIR", project_root.join("target/rust-doctor"));
cmd.args([
"clippy",
"--message-format=json",
"--all-targets",
"--all-features",
"--manifest-path",
])
.arg(&manifest_path)
.arg("--");
for flag in &warn_flags {
cmd.arg(flag);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = crate::process::spawn_in_group(&mut cmd)
.map_err(|e| format!("failed to spawn cargo clippy: {e}"))?;
let stdout = child
.stdout
.take()
.ok_or("failed to capture clippy stdout")?;
let stderr = child.stderr.take();
let (cancel_tx, cancel_rx) = mpsc::channel::<()>();
let child = Arc::new(Mutex::new(child));
let child_watcher = Arc::clone(&child);
let timed_out = Arc::new(AtomicBool::new(false));
let timed_out_watcher = Arc::clone(&timed_out);
let watcher = thread::spawn(move || {
if cancel_rx
.recv_timeout(Duration::from_secs(CLIPPY_TIMEOUT_SECS))
.is_err()
&& let Ok(mut c) = child_watcher.lock()
&& matches!(c.try_wait(), Ok(None))
{
crate::process::kill_process_tree(&mut c); let _ = c.wait(); timed_out_watcher.store(true, Ordering::Relaxed);
}
});
let reader = BufReader::new(stdout);
let mut diagnostics = Vec::new();
let mut build_succeeded = true;
for message in Message::parse_stream(reader) {
let Ok(message) = message else {
continue;
};
match message {
Message::CompilerMessage(compiler_msg) => {
let mut diag = compiler_msg.message;
if let Some(diagnostic) = process_compiler_message(&mut diag) {
diagnostics.push(diagnostic);
}
}
Message::BuildFinished(finished) => {
build_succeeded = finished.success;
}
_ => {}
}
}
let _ = cancel_tx.send(());
let _ = watcher.join();
if let Ok(mut c) = child.lock() {
let _ = c.wait();
}
if timed_out.load(Ordering::Relaxed) {
eprintln!(
"Warning: clippy timed out after {CLIPPY_TIMEOUT_SECS}s — reporting partial results"
);
}
if !build_succeeded && !diagnostics.iter().any(|d| d.severity == Severity::Error) {
if let Some(stderr) = stderr {
if let Some(fallback) = build_stderr_fallback(stderr) {
diagnostics.push(fallback);
}
}
}
filter_test_and_binary_lints(&mut diagnostics, project_root);
Ok(diagnostics)
}
#[cfg(test)]
mod tests {
use super::lint_registry::{LINT_REGISTRY, lookup_lint};
use super::*;
#[test]
fn test_registry_has_50_plus_entries() {
assert!(
LINT_REGISTRY.len() >= 50,
"Registry has {} entries, expected 50+",
LINT_REGISTRY.len()
);
}
#[test]
fn test_registry_no_duplicate_names() {
let names: Vec<&str> = LINT_REGISTRY.iter().map(|e| e.name).collect();
let mut seen = std::collections::HashSet::new();
for name in &names {
assert!(seen.insert(name), "Duplicate lint name in registry: {name}");
}
}
#[test]
fn test_lookup_known_lint() {
let result = lookup_lint("clippy::unwrap_used");
assert!(result.is_some());
let (cat, sev, restriction) = result.unwrap();
assert_eq!(cat, Category::ErrorHandling);
assert_eq!(sev, Severity::Warning);
assert!(restriction, "unwrap_used should be marked as restriction");
}
#[test]
fn test_lookup_without_prefix() {
let result = lookup_lint("unwrap_used");
assert!(result.is_some());
assert_eq!(result.unwrap().0, Category::ErrorHandling);
}
#[test]
fn test_lookup_unknown_lint() {
assert!(lookup_lint("clippy::some_unknown_lint").is_none());
}
#[test]
fn test_map_error_handling() {
assert_eq!(
map_lint_category("clippy::unwrap_used"),
Category::ErrorHandling
);
assert_eq!(
map_lint_category("clippy::expect_used"),
Category::ErrorHandling
);
assert_eq!(map_lint_category("clippy::panic"), Category::ErrorHandling);
}
#[test]
fn test_map_performance() {
assert_eq!(
map_lint_category("clippy::clone_on_copy"),
Category::Performance
);
assert_eq!(
map_lint_category("clippy::needless_collect"),
Category::Performance
);
}
#[test]
fn test_map_security() {
assert_eq!(
map_lint_category("clippy::transmute_ptr_to_ref"),
Category::Security
);
assert_eq!(
map_lint_category("clippy::undocumented_unsafe_blocks"),
Category::Security
);
}
#[test]
fn test_map_correctness() {
assert_eq!(
map_lint_category("clippy::float_cmp"),
Category::Correctness
);
assert_eq!(
map_lint_category("clippy::almost_swapped"),
Category::Correctness
);
assert_eq!(map_lint_category("compiler-error"), Category::Correctness);
assert_eq!(map_lint_category("compiler-ice"), Category::Correctness);
}
#[test]
fn test_map_cargo() {
assert_eq!(
map_lint_category("clippy::wildcard_dependencies"),
Category::Cargo
);
}
#[test]
fn test_map_async() {
assert_eq!(
map_lint_category("clippy::await_holding_lock"),
Category::Async
);
assert_eq!(map_lint_category("clippy::unused_async"), Category::Async);
}
#[test]
fn test_map_architecture() {
assert_eq!(
map_lint_category("clippy::cognitive_complexity"),
Category::Architecture
);
assert_eq!(
map_lint_category("clippy::too_many_arguments"),
Category::Architecture
);
}
#[test]
fn test_map_style() {
assert_eq!(map_lint_category("clippy::dbg_macro"), Category::Style);
assert_eq!(map_lint_category("clippy::todo"), Category::Style);
}
#[test]
fn test_map_unknown_falls_to_style() {
assert_eq!(
map_lint_category("clippy::some_unknown_lint"),
Category::Style
);
}
#[test]
fn test_severity_restriction_lints_are_warning() {
let sev = resolve_severity("clippy::unwrap_used", Severity::Warning);
assert_eq!(sev, Severity::Warning);
let sev = resolve_severity("clippy::expect_used", Severity::Warning);
assert_eq!(sev, Severity::Warning);
let sev = resolve_severity("clippy::panic", Severity::Warning);
assert_eq!(sev, Severity::Warning);
}
#[test]
fn test_severity_override_keeps_registered_warning() {
let sev = resolve_severity("clippy::clone_on_copy", Severity::Warning);
assert_eq!(sev, Severity::Warning);
}
#[test]
fn test_severity_unknown_lint_keeps_clippy_default() {
let sev = resolve_severity("clippy::some_unknown_lint", Severity::Warning);
assert_eq!(sev, Severity::Warning);
}
#[test]
fn test_severity_compiler_error_always_error() {
assert_eq!(
resolve_severity("compiler-error", Severity::Warning),
Severity::Error
);
assert_eq!(
resolve_severity("compiler-ice", Severity::Warning),
Severity::Error
);
}
#[test]
fn test_known_lint_names_count() {
let names = known_lint_names();
assert!(names.len() >= 50);
assert!(names.contains(&"unwrap_used"));
assert!(names.contains(&"await_holding_lock"));
}
#[test]
fn test_build_clippy_warn_flags_contains_groups() {
let flags = build_clippy_warn_flags();
assert!(flags.contains(&"clippy::all".to_string()));
assert!(flags.contains(&"clippy::pedantic".to_string()));
assert!(flags.contains(&"clippy::nursery".to_string()));
assert!(flags.contains(&"clippy::cargo".to_string()));
}
#[test]
fn test_build_clippy_warn_flags_contains_restriction_lints() {
let flags = build_clippy_warn_flags();
assert!(flags.contains(&"clippy::unwrap_used".to_string()));
assert!(flags.contains(&"clippy::expect_used".to_string()));
assert!(flags.contains(&"clippy::dbg_macro".to_string()));
}
#[test]
fn test_is_restriction_lint() {
assert!(is_restriction_lint("clippy::unwrap_used"));
assert!(is_restriction_lint("clippy::expect_used"));
assert!(is_restriction_lint("clippy::panic"));
assert!(is_restriction_lint("clippy::indexing_slicing"));
assert!(is_restriction_lint("clippy::print_stdout"));
assert!(is_restriction_lint("clippy::dbg_macro"));
assert!(!is_restriction_lint("clippy::clone_on_copy"));
assert!(!is_restriction_lint("clippy::almost_swapped"));
assert!(!is_restriction_lint("clippy::some_unknown_lint"));
}
#[test]
fn test_is_test_file() {
assert!(is_test_file(Path::new("tests/integration.rs")));
assert!(is_test_file(Path::new("/home/user/project/tests/foo.rs")));
assert!(!is_test_file(Path::new("src/main.rs")));
assert!(!is_test_file(Path::new("src/rules/mod.rs")));
}
#[test]
fn test_clippy_is_available() {
assert!(is_clippy_available());
}
#[test]
fn test_run_clippy_on_self() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let result = run_clippy(manifest_dir);
assert!(result.is_ok(), "clippy failed: {:?}", result.err());
let diags = result.unwrap();
for d in &diags {
if let Some((_, expected_sev, _)) = lookup_lint(&d.rule) {
assert_eq!(
d.severity, expected_sev,
"Lint {} should have severity {:?} but got {:?}",
d.rule, expected_sev, d.severity
);
}
}
for d in &diags {
if is_test_file(&d.file_path) {
assert!(
!is_restriction_lint(&d.rule),
"Restriction lint {} should have been filtered from test file {:?}",
d.rule,
d.file_path
);
}
}
}
}