use std::path::Path;
use anyhow::Result;
use serde_json::Value;
use super::run_command;
use crate::core::tools::{Severity, StaticTool, ToolDiagnostic};
pub struct ClippyTool;
impl StaticTool for ClippyTool {
fn name(&self) -> &str {
"clippy"
}
fn language(&self) -> &str {
"rust"
}
fn is_available(&self) -> bool {
which::which("cargo").is_ok()
}
fn run(&self, file: &Path, _content: &str) -> Result<Vec<ToolDiagnostic>> {
let dir = file.parent().unwrap_or_else(|| Path::new("."));
let out = match run_command(
"cargo",
&["clippy", "--message-format=json", "--quiet"],
dir,
) {
Ok(o) => o,
Err(e) => {
tracing::debug!("clippy invocation failed: {e}");
return Ok(Vec::new());
}
};
Ok(parse_clippy_json(&out.stdout, file))
}
}
fn parse_clippy_json(stdout: &str, file: &Path) -> Vec<ToolDiagnostic> {
let want = file.to_string_lossy();
let mut diags = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(root) = serde_json::from_str::<Value>(line) else {
continue;
};
let Some(msg) = root.get("message") else {
continue;
};
if let Some(d) = clippy_message_to_diag(msg, &want) {
diags.push(d);
}
}
diags
}
fn clippy_message_to_diag(msg: &Value, want_file: &str) -> Option<ToolDiagnostic> {
let level = msg.get("level").and_then(Value::as_str).unwrap_or("");
if level == "note" || level.is_empty() {
return None;
}
let spans = msg.get("spans").and_then(Value::as_array)?;
let span = spans
.iter()
.find(|s| {
s.get("is_primary")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.or_else(|| spans.first())?;
let span_file = span.get("file_name").and_then(Value::as_str)?;
if !file_matches(span_file, want_file) {
return None;
}
let line = span.get("line_start").and_then(Value::as_u64).unwrap_or(0) as u32;
let col = span
.get("column_start")
.and_then(Value::as_u64)
.unwrap_or(0) as u32;
let message = msg
.get("rendered")
.and_then(Value::as_str)
.or_else(|| msg.get("message").and_then(Value::as_str))
.unwrap_or("")
.trim()
.to_string();
let code = msg
.get("code")
.and_then(|c| c.get("code"))
.and_then(Value::as_str)
.map(str::to_string);
Some(ToolDiagnostic {
tool: "clippy".into(),
file: span_file.to_string(),
line,
col,
severity: severity_from_level(level),
code,
message,
})
}
fn file_matches(span_file: &str, want: &str) -> bool {
span_file == want || want.ends_with(span_file) || span_file.ends_with(want)
}
fn severity_from_level(level: &str) -> Severity {
match level {
"error" => Severity::Error,
"warning" => Severity::Warning,
"help" => Severity::Hint,
_ => Severity::Info,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_clippy_json_extracts_warning() {
let line = r#"{"reason":"compiler-message","message":{"level":"warning","message":"unneeded return","rendered":"warning: unneeded return statement","code":{"code":"clippy::needless_return"},"spans":[{"is_primary":true,"file_name":"src/main.rs","line_start":7,"column_start":5}]}}"#;
let diags = parse_clippy_json(line, Path::new("src/main.rs"));
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].line, 7);
assert_eq!(diags[0].severity, Severity::Warning);
assert_eq!(diags[0].code.as_deref(), Some("clippy::needless_return"));
}
#[test]
fn parse_clippy_json_skips_notes_and_other_files() {
let note = r#"{"message":{"level":"note","message":"n","spans":[{"is_primary":true,"file_name":"src/main.rs","line_start":1,"column_start":1}]}}"#;
let other = r#"{"message":{"level":"warning","message":"w","spans":[{"is_primary":true,"file_name":"other.rs","line_start":1,"column_start":1}]}}"#;
let input = format!("{note}\n{other}\n");
let diags = parse_clippy_json(&input, Path::new("src/main.rs"));
assert!(diags.is_empty());
}
#[test]
fn parse_clippy_json_tolerates_garbage() {
let diags = parse_clippy_json("not json\n{}\n", Path::new("src/main.rs"));
assert!(diags.is_empty());
}
}