prview 0.4.0

PR Review & Artifact Generator - cross-language PR analysis tool
Documentation
use super::LintFinding;
use regex::Regex;
use std::sync::LazyLock;

static FILE_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^(/[^\s].*|[A-Z]:\\[^\s].*)$").unwrap());

static FINDING_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)(?:\s{2,}(\S+))?\s*$").unwrap()
});

pub fn parse_eslint_output(output: &str) -> Vec<LintFinding> {
    let mut findings = Vec::new();
    let mut current_file: Option<String> = None;

    for line in output.lines() {
        let trimmed = line.trim_end();

        if FILE_RE.is_match(trimmed) {
            current_file = Some(trimmed.to_string());
            continue;
        }

        if trimmed.starts_with('') || trimmed.starts_with("") {
            continue;
        }

        let Some(file) = current_file.as_ref() else {
            continue;
        };

        let Some(caps) = FINDING_RE.captures(trimmed) else {
            continue;
        };

        findings.push(LintFinding {
            file: file.clone(),
            line: caps[1].parse().unwrap_or_default(),
            column: caps[2].parse().ok(),
            level: if caps.get(3).map(|m| m.as_str()) == Some("error") {
                "error"
            } else {
                "warning"
            },
            message: caps[4].trim().to_string(),
            rule_id: caps
                .get(5)
                .map(|m: regex::Match<'_>| m.as_str().to_string()),
            source: "eslint",
        });
    }

    findings
}

#[cfg(test)]
mod tests {
    use super::parse_eslint_output;

    #[test]
    fn parses_single_error_with_rule_id() {
        let output = "/src/app.ts\n  10:5  error  Unexpected var  no-var\n";
        let findings = parse_eslint_output(output);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].file, "/src/app.ts");
        assert_eq!(findings[0].line, 10);
        assert_eq!(findings[0].column, Some(5));
        assert_eq!(findings[0].level, "error");
        assert_eq!(findings[0].rule_id.as_deref(), Some("no-var"));
    }

    #[test]
    fn parses_multiple_findings_in_one_file() {
        let output = "/src/foo.ts\n  1:1  error  msg1  rule-a\n  2:3  warning  msg2  rule-b\n";
        let findings = parse_eslint_output(output);
        assert_eq!(findings.len(), 2);
        assert_eq!(findings[0].level, "error");
        assert_eq!(findings[1].level, "warning");
    }

    #[test]
    fn parses_multiple_files() {
        let output = "/a.ts\n  1:1  error  msg  r1\n\n/b.ts\n  5:2  warning  msg2  r2\n";
        let findings = parse_eslint_output(output);
        assert_eq!(findings.len(), 2);
        assert_eq!(findings[0].file, "/a.ts");
        assert_eq!(findings[1].file, "/b.ts");
    }

    #[test]
    fn parses_finding_without_rule_id() {
        let output = "/src/bad.ts\n  3:1  error  Parsing error: Unexpected token\n";
        let findings = parse_eslint_output(output);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].rule_id, None);
    }

    #[test]
    fn returns_empty_for_empty_output() {
        assert!(parse_eslint_output("").is_empty());
    }

    #[test]
    fn skips_summary_only_output() {
        let output = "\n✖ 0 problems (0 errors, 0 warnings)\n";
        assert!(parse_eslint_output(output).is_empty());
    }

    #[test]
    fn supports_absolute_unix_and_windows_paths() {
        let output = "/tmp/src/app.ts\n  4:2  error  unix issue  no-console\n\nC:\\repo\\src\\main.ts\n  8:7  warning  win issue  no-alert\n";
        let findings = parse_eslint_output(output);
        assert_eq!(findings.len(), 2);
        assert_eq!(findings[0].file, "/tmp/src/app.ts");
        assert_eq!(findings[1].file, "C:\\repo\\src\\main.ts");
        assert_eq!(findings[1].level, "warning");
    }
}