use super::*;
pub(crate) fn is_lint_check(name: &str) -> bool {
let lower = name.to_lowercase();
lower.contains("clippy")
|| lower.contains("eslint")
|| lower.contains("ruff")
|| lower.contains("mypy")
|| lower.contains("lint")
|| lower.contains("pylint")
|| lower.contains("biome")
|| lower.contains("stylelint")
}
pub(crate) fn normalize_lint_path(path: &str) -> String {
let mut p = path.trim().replace('\\', "/");
if p.len() >= 3
&& p.as_bytes()[0].is_ascii_alphabetic()
&& p.as_bytes()[1] == b':'
&& p.as_bytes()[2] == b'/'
{
p = p[3..].to_string();
}
if let Some(rest) = p.strip_prefix("./") {
p = rest.to_string();
}
if let Some(rest) = p.strip_prefix('/') {
p = rest.to_string();
}
if p.contains('/') {
let markers = ["src/", "lib/", "app/", "pkg/", "crates/", "tests/", "test/"];
for marker in markers {
let needle = format!("/{marker}");
if let Some(idx) = p.find(&needle) {
let prefix = &p[..idx];
let prefix_depth = prefix.chars().filter(|&c| c == '/').count();
if prefix_depth >= 2 {
return p[idx + 1..].to_string();
}
}
}
}
p
}
pub(crate) fn parse_lint_issues(output: &str) -> Vec<String> {
let mut results = Vec::new();
let re_inline =
regex::Regex::new(r"(?m)(?:-->\s*)?([a-zA-Z0-9_.][a-zA-Z0-9_./\\\-]*\.[a-zA-Z0-9]+):(\d+)")
.expect("lint inline regex must compile");
for cap in re_inline.captures_iter(output) {
if let Some(file) = cap.get(1) {
let f = file.as_str();
if f.chars().all(|c| c.is_ascii_digit() || c == '.') {
continue;
}
results.push(normalize_lint_path(f));
}
}
let re_block_path =
regex::Regex::new(r"(?m)^([a-zA-Z0-9_./\\\-][a-zA-Z0-9_./\\\- ]*\.[a-zA-Z0-9]+)\s*$")
.expect("lint block path regex must compile");
let re_block_issue =
regex::Regex::new(r"(?m)^\s+\d+:\d+\s+").expect("lint block issue regex must compile");
let lines: Vec<&str> = output.lines().collect();
for (i, line) in lines.iter().enumerate() {
if let Some(cap) = re_block_path.captures(line) {
let Some(path) = cap.get(1).map(|m| m.as_str()) else {
debug_assert!(false, "re_block_path must capture group 1");
continue;
};
let has_issues = lines
.get(i + 1)
.is_some_and(|next| re_block_issue.is_match(next));
if has_issues {
for subsequent in &lines[i + 1..] {
if re_block_issue.is_match(subsequent) {
results.push(normalize_lint_path(path));
} else if subsequent.trim().is_empty() {
break;
} else {
break;
}
}
}
}
}
results
}
pub(crate) fn compute_lint_metrics(checks: &[CheckResult], diffs: &[Diff]) -> Vec<LintMetrics> {
use crate::checks::CheckStatus;
use std::collections::{BTreeSet, HashSet};
let changed_files: HashSet<String> = diffs
.iter()
.flat_map(|d| d.files.iter().map(|f| normalize_lint_path(&f.path)))
.collect();
let mut metrics = Vec::new();
for check in checks {
if !is_lint_check(&check.name) {
continue;
}
if check.status == CheckStatus::Error {
continue;
}
let issue_files = parse_lint_issues(&check.output);
let total_issues = issue_files.len();
if total_issues == 0 {
metrics.push(LintMetrics {
check_name: check.name.clone(),
new_issues: 0,
legacy_issues: 0,
total_issues: 0,
changed_files_with_issues: Vec::new(),
});
continue;
}
let mut new_issues = 0usize;
let mut legacy_issues = 0usize;
let mut changed_with_issues: BTreeSet<String> = BTreeSet::new();
for file in &issue_files {
if changed_files.contains(file.as_str()) {
new_issues += 1;
changed_with_issues.insert(file.clone());
} else {
legacy_issues += 1;
}
}
metrics.push(LintMetrics {
check_name: check.name.clone(),
new_issues,
legacy_issues,
total_issues,
changed_files_with_issues: changed_with_issues.into_iter().collect(),
});
}
metrics
}