use crate::checkers::Checker;
use crate::utils::types::{LintIssue, Severity};
use crate::{Language, Result};
use serde::Deserialize;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Deserialize)]
struct RuffLocation {
row: usize,
column: usize,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct RuffEdit {
content: String,
location: RuffLocation,
end_location: RuffLocation,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct RuffFix {
message: String,
applicability: String,
edits: Vec<RuffEdit>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct RuffIssue {
filename: String,
code: String,
message: String,
location: RuffLocation,
end_location: RuffLocation,
fix: Option<RuffFix>,
url: Option<String>,
}
pub struct PythonChecker;
impl PythonChecker {
pub fn new() -> Self {
Self
}
fn find_ruff_config(path: &Path) -> Option<std::path::PathBuf> {
let mut current = if path.is_file() {
path.parent()?.to_path_buf()
} else {
path.to_path_buf()
};
let config_names = [
"ruff.toml",
".ruff.toml",
"pyproject.toml",
".linthis/configs/python/ruff.toml", ".linthis/configs/python/.ruff.toml",
];
loop {
for config_name in &config_names {
let config_path = current.join(config_name);
if config_path.exists() {
return Some(config_path);
}
}
if !current.pop() {
break;
}
}
None
}
fn find_local_ruff_config(path: &Path) -> Option<std::path::PathBuf> {
let mut current = if path.is_file() {
path.parent()?.to_path_buf()
} else {
path.to_path_buf()
};
let config_names = ["ruff.toml", ".ruff.toml", "pyproject.toml"];
loop {
for config_name in &config_names {
let config_path = current.join(config_name);
if config_path.exists() {
let path_str = config_path.to_string_lossy();
if !path_str.contains(".linthis/configs/")
&& !path_str.contains(".linthis\\configs\\")
{
return Some(config_path);
}
}
}
if !current.pop() {
break;
}
}
None
}
fn parse_ruff_json_output(&self, output: &str, _file_path: &Path) -> Vec<LintIssue> {
let mut issues = Vec::new();
let ruff_issues: Vec<RuffIssue> = match serde_json::from_str(output) {
Ok(issues) => issues,
Err(_) => return issues, };
for ruff_issue in ruff_issues {
let severity = self.map_code_to_severity(&ruff_issue.code);
let mut issue = LintIssue::new(
std::path::PathBuf::from(&ruff_issue.filename),
ruff_issue.location.row,
ruff_issue.message.clone(),
severity,
)
.with_source("ruff".to_string())
.with_code(ruff_issue.code.clone())
.with_column(ruff_issue.location.column);
if let Some(fix) = &ruff_issue.fix {
issue = issue.with_suggestion(fix.message.clone());
}
issues.push(issue);
}
issues
}
fn map_code_to_severity(&self, code: &str) -> Severity {
if code.is_empty() {
return Severity::Info;
}
let prefix: String = code
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.collect();
match prefix.as_str() {
"E" | "F" => Severity::Error,
"W" | "N" | "B" | "S" | "A" | "PL" | "PLW" | "PLR" | "PLE" | "C90" => Severity::Warning,
"C" | "R" | "I" | "D" | "UP" | "YTT" | "ANN" | "BLE" | "FBT" | "COM" | "DTZ" | "EM"
| "EXE" | "FA" | "ISC" | "ICN" | "LOG" | "G" | "INP" | "PIE" | "T20" | "PYI" | "PT"
| "Q" | "RSE" | "RET" | "SLF" | "SLOT" | "SIM" | "TID" | "TCH" | "INT" | "ARG"
| "PTH" | "TD" | "FIX" | "ERA" | "PD" | "PGH" | "TRY" | "FLY" | "NPY" | "AIR"
| "PERF" | "FURB" | "RUF" => Severity::Info,
_ => Severity::Info,
}
}
}
impl Default for PythonChecker {
fn default() -> Self {
Self::new()
}
}
impl Checker for PythonChecker {
fn name(&self) -> &str {
"ruff"
}
fn supported_languages(&self) -> &[Language] {
&[Language::Python]
}
fn check(&self, path: &Path) -> Result<Vec<LintIssue>> {
self.check_with_config(path, None)
}
fn check_with_config(&self, path: &Path, config: Option<&Path>) -> Result<Vec<LintIssue>> {
let mut cmd = Command::new("ruff");
cmd.args(["check", "--output-format", "json"]);
let effective_config = if config.is_some() {
if let Some(local_config) = Self::find_local_ruff_config(path) {
Some(local_config)
} else {
config.map(|p| p.to_path_buf())
}
} else {
Self::find_ruff_config(path)
};
if let Some(config_path) = effective_config {
cmd.arg("--config").arg(&config_path);
} else {
cmd.args(["--select", "E,W,F"]);
}
let output = cmd.arg(path).output().map_err(|e| {
crate::LintisError::checker("ruff", path, format!("Failed to run: {}", e))
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.code() == Some(2)
&& stdout.is_empty()
&& stderr.contains("Failed to parse")
{
let mut retry_cmd = Command::new("ruff");
retry_cmd.args(["check", "--output-format", "json"]);
retry_cmd.args(["--select", "E,W,F"]);
let retry_output = retry_cmd.arg(path).output().map_err(|e| {
crate::LintisError::checker("ruff", path, format!("Failed to run: {}", e))
})?;
let retry_stdout = String::from_utf8_lossy(&retry_output.stdout);
return Ok(self.parse_ruff_json_output(&retry_stdout, path));
}
let issues = self.parse_ruff_json_output(&stdout, path);
Ok(issues)
}
fn is_available(&self) -> bool {
Command::new("ruff")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_mapping() {
let checker = PythonChecker::new();
assert_eq!(checker.map_code_to_severity("E501"), Severity::Error);
assert_eq!(checker.map_code_to_severity("F401"), Severity::Error);
assert_eq!(checker.map_code_to_severity("W503"), Severity::Warning);
assert_eq!(checker.map_code_to_severity("N801"), Severity::Warning);
assert_eq!(checker.map_code_to_severity("B006"), Severity::Warning);
assert_eq!(checker.map_code_to_severity("S101"), Severity::Warning);
assert_eq!(checker.map_code_to_severity("I001"), Severity::Info);
assert_eq!(checker.map_code_to_severity("D100"), Severity::Info);
assert_eq!(checker.map_code_to_severity("UP035"), Severity::Info);
assert_eq!(checker.map_code_to_severity("RUF001"), Severity::Info);
}
#[test]
fn test_parse_ruff_json_output() {
let checker = PythonChecker::new();
let json = r#"[
{
"cell": null,
"code": "F401",
"end_location": {"column": 10, "row": 1},
"filename": "test.py",
"fix": {
"applicability": "safe",
"edits": [{"content": "", "end_location": {"column": 10, "row": 1}, "location": {"column": 0, "row": 1}}],
"message": "Remove unused import: `os`"
},
"location": {"column": 8, "row": 1},
"message": "`os` imported but unused",
"noqa_row": 1,
"url": "https://docs.astral.sh/ruff/rules/unused-import"
}
]"#;
let issues = checker.parse_ruff_json_output(json, Path::new("test.py"));
assert_eq!(issues.len(), 1);
let issue = &issues[0];
assert_eq!(issue.code, Some("F401".to_string()));
assert_eq!(issue.message, "`os` imported but unused");
assert_eq!(issue.line, 1);
assert_eq!(issue.column, Some(8));
assert_eq!(issue.severity, Severity::Error);
assert_eq!(issue.source, Some("ruff".to_string()));
assert_eq!(
issue.suggestion,
Some("Remove unused import: `os`".to_string())
);
}
#[test]
fn test_parse_empty_output() {
let checker = PythonChecker::new();
let issues = checker.parse_ruff_json_output("[]", Path::new("test.py"));
assert!(issues.is_empty());
}
#[test]
fn test_parse_invalid_json() {
let checker = PythonChecker::new();
let issues = checker.parse_ruff_json_output("not valid json", Path::new("test.py"));
assert!(issues.is_empty());
}
}