use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
static OFFENSE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^([^:]+:\d+:\d+): (.+) \(([a-zA-Z0-9_/-]+)\)$").unwrap());
static LEVEL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^(WARN|ERROR|INFO)\s+\[([a-zA-Z0-9_/-]+)\]").unwrap());
static PROGRESS_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^(level=info|time=|msg=|Finished linters|Running linters)[^\n]*\n?").unwrap()
});
static DIFF_HUNK_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?m)^(\+\+\+|---|@@|[ +\-])[^\n]*\n?").unwrap());
static SUMMARY_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(Run duration|Issues:|linters used|linters enabled|linters disabled)[^\n]*")
.unwrap()
});
fn linter_category(name: &str) -> &'static str {
match name {
"govet" | "vet" => "govet",
"errcheck" => "errcheck",
"staticcheck" | "SA1000" | "SA1001" | "SA4006" | "SA9003" => "staticcheck",
"stylecheck" | "ST1000" | "ST1003" | "ST1020" | "ST1021" | "ST1023" => "stylecheck",
"gocritic" => "gocritic",
_ => "other",
}
}
pub fn compress_golangci(raw: &str, exit_code: i32) -> String {
let cleaned = compactor::normalise(raw);
if cleaned.trim_start().starts_with('[') && cleaned.contains("\"FromLinter\"")
|| cleaned.contains("\"Issues\"")
{
return compress_golangci_json(&cleaned, exit_code);
}
if cleaned.contains("--- a/") || cleaned.contains("+++ b/") {
return compress_golangci_fix(&cleaned, exit_code);
}
let s = PROGRESS_RE.replace_all(&cleaned, "");
if exit_code == 0 {
return "golangci-lint: no issues found".to_string();
}
let mut summaries: Vec<String> = Vec::new();
for m in SUMMARY_RE.find_iter(&s) {
summaries.push(m.as_str().to_string());
}
let mut linter_counts: HashMap<String, usize> = HashMap::new();
let mut error_lines: Vec<String> = Vec::new();
for line in s.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if SUMMARY_RE.is_match(t) {
continue;
}
if let Some(caps) = OFFENSE_RE.captures(t) {
let linter = caps[3].to_string();
*linter_counts.entry(linter).or_insert(0) += 1;
continue;
}
if let Some(caps) = LEVEL_RE.captures(t) {
let linter = caps[2].to_string();
*linter_counts.entry(linter).or_insert(0) += 1;
continue;
}
if t.starts_with("FAIL")
|| t.contains("issue")
|| t.contains("error")
|| t.starts_with("level=error")
{
error_lines.push(line.to_string());
}
}
let mut by_category: HashMap<&str, Vec<(String, usize)>> = HashMap::new();
for (linter, count) in &linter_counts {
let cat = linter_category(linter);
by_category
.entry(cat)
.or_default()
.push((linter.clone(), *count));
}
let mut grouped: Vec<String> = Vec::new();
let mut categories: Vec<&&str> = by_category.keys().collect();
categories.sort();
for cat in categories {
let linters = &by_category[cat];
if linters.len() == 1 {
let (name, count) = &linters[0];
if *count > 1 {
grouped.push(format!("{name} (×{count})"));
} else {
grouped.push(name.clone());
}
} else {
let total: usize = linters.iter().map(|(_, c)| c).sum();
let names: Vec<String> = {
let mut v: Vec<_> = linters.iter().collect();
v.sort_by_key(|(n, _)| n.clone());
v.iter()
.map(|(n, c)| {
if *c > 1 {
format!("{n}(×{c})")
} else {
n.clone()
}
})
.collect()
};
grouped.push(format!("[{cat}] {total} issues — {}", names.join(", ")));
}
}
grouped.sort();
let mut result = summaries;
result.extend(grouped);
result.extend(error_lines);
compactor::collapse_blanks(&result.join("\n"))
}
fn compress_golangci_json(raw: &str, exit_code: i32) -> String {
if exit_code == 0 {
return "golangci-lint: no issues found".to_string();
}
let mut counts: HashMap<String, usize> = HashMap::new();
let linter_re = Regex::new(r#""FromLinter"\s*:\s*"([^"]+)""#).expect("regex compile");
for cap in linter_re.captures_iter(raw) {
*counts.entry(cap[1].to_string()).or_insert(0) += 1;
}
if counts.is_empty() {
return compactor::collapse_blanks(raw);
}
let total: usize = counts.values().sum();
let mut parts: Vec<String> = counts
.iter()
.map(|(k, v)| {
if *v > 1 {
format!("{k}(×{v})")
} else {
k.clone()
}
})
.collect();
parts.sort();
format!(
"golangci-lint [json]: {total} issues — {}",
parts.join(", ")
)
}
fn compress_golangci_fix(raw: &str, _exit_code: i32) -> String {
let file_re = Regex::new(r"--- a/([^\s]+)").expect("regex compile");
let files: Vec<&str> = file_re
.captures_iter(raw)
.filter_map(|c| c.get(1).map(|m| m.as_str()))
.collect();
if files.is_empty() {
return DIFF_HUNK_RE.replace_all(raw, "").to_string();
}
let unique: std::collections::HashSet<&str> = files.iter().copied().collect();
format!(
"golangci-lint --fix: {} fixes across {} files",
files.len(),
unique.len()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success_returns_clean_message() {
let raw =
"level=info msg=\"Running linters...\"\nlevel=info msg=\"Finished linters in 2.1s\"\n";
let out = compress_golangci(raw, 0);
assert!(out.contains("no issues"), "{out}");
}
#[test]
fn groups_repeated_linter_offenses() {
let raw =
(0..5)
.map(|i| format!("pkg/foo.go:{i}:1: use of unsafe.Pointer (govet)\n"))
.chain((0..3).map(|i| {
format!("pkg/bar.go:{i}:1: exported function lacks comment (golint)\n")
}))
.collect::<String>();
let out = compress_golangci(&raw, 1);
assert!(out.contains("×5") || out.contains("(×5)"), "govet: {out}");
assert!(out.contains("×3") || out.contains("(×3)"), "golint: {out}");
}
#[test]
fn strips_progress_info_lines() {
let raw = "level=info msg=\"Running linters...\"\ntime=\"12:00\" level=info msg=\"Starting analysis\"\npkg/a.go:1:1: something wrong (staticcheck)\n";
let out = compress_golangci(raw, 1);
assert!(!out.contains("Running linters"), "{out}");
assert!(!out.contains("Starting analysis"), "{out}");
}
#[test]
fn json_mode_extracts_linter_counts() {
let raw = r#"[{"FromLinter":"govet","Text":"printf arg mismatch"},{"FromLinter":"govet","Text":"another issue"},{"FromLinter":"errcheck","Text":"unchecked error"}]"#;
let out = compress_golangci(raw, 1);
assert!(out.contains("json"), "{out}");
assert!(out.contains("govet"), "{out}");
assert!(out.contains("×2"), "{out}");
}
#[test]
fn fix_mode_summarises_diff() {
let raw = "--- a/pkg/foo.go\n+++ b/pkg/foo.go\n@@ -1,3 +1,3 @@\n-bad\n+good\n--- a/pkg/bar.go\n+++ b/pkg/bar.go\n@@ -5,1 +5,1 @@\n-old\n+new\n";
let out = compress_golangci(raw, 0);
assert!(out.contains("fix") || out.contains("fixes"), "{out}");
}
#[test]
fn category_grouping_staticcheck() {
let raw = (0..4)
.map(|i| format!("pkg/a.go:{i}:1: unused var (staticcheck)\n"))
.chain((0..2).map(|i| format!("pkg/b.go:{i}:1: style issue (stylecheck)\n")))
.collect::<String>();
let out = compress_golangci(&raw, 1);
assert!(out.contains("staticcheck") || out.contains("×4"), "{out}");
}
#[test]
fn exit_zero_always_clean() {
let raw = "pkg/a.go:1:1: something (govet)\n";
let out = compress_golangci(raw, 0);
assert!(out.contains("no issues"), "{out}");
}
}