use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;
static DIAG_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^([^:]+\.py):(\d+):(\d+):\s+([CRWEF]\d{4}):\s+(.+?)(?:\s+\(([^)]+)\))?\s*$")
.unwrap()
});
static SCORE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?m)^Your code has been rated at[^\n]*\n?").unwrap());
static MODULE_HEADER_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?m)^\*{4,}[^\n]*Module[^\n]*\n?").unwrap());
pub fn compress_pylint(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let score = SCORE_RE
.find(&cleaned)
.map(|m| m.as_str().trim().to_string());
let s = MODULE_HEADER_RE.replace_all(&cleaned, "");
let mut by_file: std::collections::HashMap<&str, Vec<(u32, &str, &str, &str)>> =
std::collections::HashMap::new();
let mut error_count = 0usize;
let mut warning_count = 0usize;
let mut convention_count = 0usize;
for caps in DIAG_RE.captures_iter(&s) {
let file = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let line: u32 = caps
.get(2)
.and_then(|m| m.as_str().parse().ok())
.unwrap_or(0);
let code = caps.get(4).map(|m| m.as_str()).unwrap_or("");
let msg = caps.get(5).map(|m| m.as_str()).unwrap_or("").trim();
let symbol = caps.get(6).map(|m| m.as_str()).unwrap_or("");
match code.chars().next() {
Some('E') | Some('F') => error_count += 1,
Some('W') => warning_count += 1,
_ => convention_count += 1,
}
by_file
.entry(file)
.or_default()
.push((line, code, msg, symbol));
}
if by_file.is_empty() {
let result = SCORE_RE.replace_all(&s, "");
let result = compactor::collapse_blanks(&result);
if let Some(sc) = score {
return format!("{}\n{sc}", result.trim());
}
return result;
}
let mut out_lines: Vec<String> = Vec::new();
let mut files: Vec<&&str> = by_file.keys().collect();
files.sort();
for file in files {
let diags = &by_file[file];
let mut sorted = diags.clone();
sorted.sort_by_key(|(l, _, _, _)| *l);
out_lines.push(file.to_string());
for (i, (line, code, msg, symbol)) in sorted.iter().enumerate() {
if i >= 10 {
out_lines.push(format!(
" … {} more issues in this file",
sorted.len() - 10
));
break;
}
if symbol.is_empty() {
out_lines.push(format!(" {line} {code} {msg}"));
} else {
out_lines.push(format!(" {line} {code} {msg} ({symbol})"));
}
}
}
let total = error_count + warning_count + convention_count;
out_lines.push(format!(
"\nTotal: {} [{} errors, {} warnings, {} convention]",
total, error_count, warning_count, convention_count
));
if let Some(sc) = score {
out_lines.push(sc);
}
out_lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_score_from_body_but_keeps_at_end() {
let raw = "************* Module mymodule\nmymodule.py:10:0: W0611: Unused import os (unused-import)\n\n-------------------------------------------------------------------\nYour code has been rated at 8.50/10 (previous run: 7.00/10, +1.50)\n";
let out = compress_pylint(raw);
assert!(out.contains("8.50/10"), "{out}");
assert!(!out.contains("Module mymodule"), "{out}");
assert!(out.contains("W0611"), "{out}");
}
#[test]
fn groups_by_file_with_summary() {
let raw = "app.py:5:0: E0001: invalid syntax (syntax-error)\napp.py:12:4: W0611: Unused import sys (unused-import)\nutils.py:3:0: C0114: Missing module docstring (missing-module-docstring)\n";
let out = compress_pylint(raw);
assert!(out.contains("app.py"), "{out}");
assert!(out.contains("utils.py"), "{out}");
assert!(out.contains("Total:"), "{out}");
}
#[test]
fn caps_per_file_at_10() {
let lines: Vec<String> = (1..=15)
.map(|i| format!("big.py:{i}:0: W0611: Unused import x{i} (unused-import)"))
.collect();
let out = compress_pylint(&lines.join("\n"));
assert!(out.contains("more issues"), "{out}");
}
#[test]
fn handles_no_diagnostics() {
let raw = "--------------------------------------------------------------------\nYour code has been rated at 10.00/10\n";
let out = compress_pylint(raw);
assert!(out.contains("10.00/10"), "{out}");
}
}