use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;
static FILE_HEADER_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?m)^[^\s].*\.(css|scss|sass|less|styl|vue|svelte)[^\n]*$").unwrap());
static DIAG_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^\s+(\d+:\d+)\s+([✕⚠×!]|error|warning)\s+(.+?)\s{2,}(\S+)\s*$").unwrap()
});
pub fn compress_stylelint(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let mut file_sections: Vec<String> = Vec::new();
let mut current_file: Option<String> = None;
let mut current_diags: Vec<String> = Vec::new();
let mut error_count = 0usize;
let mut warning_count = 0usize;
for line in cleaned.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if FILE_HEADER_RE.is_match(t) && !t.starts_with(' ') {
if let Some(file) = current_file.take() {
if !current_diags.is_empty() {
file_sections.push(file);
file_sections.append(&mut current_diags);
}
}
current_file = Some(line.to_string());
} else if let Some(caps) = DIAG_RE.captures(line) {
let loc = caps.get(1).map(|m| m.as_str()).unwrap_or("?");
let severity = caps.get(2).map(|m| m.as_str()).unwrap_or("?");
let msg = caps.get(3).map(|m| m.as_str()).unwrap_or("?").trim();
let rule = caps.get(4).map(|m| m.as_str()).unwrap_or("?");
let is_error =
severity == "✕" || severity == "×" || severity == "!" || severity == "error";
if is_error {
error_count += 1;
} else {
warning_count += 1;
}
current_diags.push(format!(" {loc} {severity} {msg} {rule}"));
} else if t.contains("problem") || t.contains("error") || t.contains("warning") {
if let Some(file) = current_file.take() {
if !current_diags.is_empty() {
file_sections.push(file);
file_sections.append(&mut current_diags);
}
}
file_sections.push(line.to_string());
}
}
if let Some(file) = current_file {
if !current_diags.is_empty() {
file_sections.push(file);
file_sections.extend(current_diags);
}
}
if file_sections.is_empty() {
return compactor::collapse_blanks(&cleaned);
}
let mut result = file_sections.join("\n");
if error_count > 0 || warning_count > 0 {
result.push_str(&format!(
"\n\nSummary: {} errors, {} warnings",
error_count, warning_count
));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn groups_errors_by_file() {
let raw = "src/styles/main.css\n 1:5 ✕ Expected indentation of 2 spaces indentation\n 2:1 ⚠ Unexpected empty line before closing brace block-closing-brace-empty-line-before\n\nsrc/styles/button.css\n 5:3 ✕ Unexpected invalid hex color \"#gggggg\" color-no-invalid-hex\n\n3 problems (2 errors, 1 warning)\n";
let out = compress_stylelint(raw);
assert!(out.contains("main.css"), "{out}");
assert!(out.contains("button.css"), "{out}");
assert!(out.contains("indentation"), "{out}");
assert!(out.contains("color-no-invalid-hex"), "{out}");
}
#[test]
fn passthrough_on_no_issues() {
let raw = "No issues found\n";
let out = compress_stylelint(raw);
assert!(out.contains("No issues found") || out.is_empty(), "{out}");
}
#[test]
fn handles_scss_files() {
let raw = "src/components/_button.scss\n 10:5 ✕ Unexpected vendor-prefix \"-webkit-\" vendor-prefix\n\n1 problem (1 error, 0 warnings)\n";
let out = compress_stylelint(raw);
assert!(out.contains("_button.scss"), "{out}");
assert!(out.contains("vendor-prefix"), "{out}");
}
}