Skip to main content

aft/compress/
eslint.rs

1use std::collections::BTreeMap;
2
3use serde_json::Value;
4
5use crate::compress::generic::{dedup_consecutive, middle_truncate, strip_ansi, GenericCompressor};
6use crate::compress::Compressor;
7
8const MAX_LINES: usize = 200;
9const MAX_ISSUES_PER_FILE: usize = 10;
10
11pub struct EslintCompressor;
12
13#[derive(Clone, Debug)]
14struct Issue {
15    line: usize,
16    column: usize,
17    severity: String,
18    message: String,
19    rule: Option<String>,
20}
21
22impl Compressor for EslintCompressor {
23    fn matches(&self, command: &str) -> bool {
24        command_tokens(command).any(|token| token == "eslint")
25    }
26
27    fn compress(&self, _command: &str, output: &str) -> String {
28        compress_eslint(output)
29    }
30}
31
32fn compress_eslint(output: &str) -> String {
33    let trimmed = output.trim_start();
34    if trimmed.starts_with("[{") {
35        if let Some(compressed) = compress_json(trimmed) {
36            return finish(&compressed);
37        }
38        return GenericCompressor::compress_output(output);
39    }
40
41    if let Some(compressed) = compress_text(output) {
42        return finish(&compressed);
43    }
44
45    GenericCompressor::compress_output(output)
46}
47
48fn command_tokens(command: &str) -> impl Iterator<Item = String> + '_ {
49    command
50        .split_whitespace()
51        .map(|token| token.trim_matches(|ch| matches!(ch, '\'' | '"')))
52        .filter(|token| !matches!(*token, "npx" | "pnpm" | "yarn" | "bun" | "bunx"))
53        .map(|token| {
54            token
55                .rsplit(['/', '\\'])
56                .next()
57                .unwrap_or(token)
58                .trim_end_matches(".cmd")
59                .to_string()
60        })
61}
62
63fn compress_json(input: &str) -> Option<String> {
64    let results: Value = serde_json::from_str(input).ok()?;
65    let files = results.as_array()?;
66    let mut grouped = BTreeMap::new();
67    let mut errors = 0usize;
68    let mut warnings = 0usize;
69
70    for file in files {
71        let path = string_field(file, "filePath").unwrap_or("<unknown>");
72        let mut issues = Vec::new();
73        for message in file
74            .get("messages")
75            .and_then(Value::as_array)
76            .into_iter()
77            .flatten()
78        {
79            let severity = severity_name(message.get("severity"));
80            if severity == "error" {
81                errors += 1;
82            } else if severity == "warning" {
83                warnings += 1;
84            }
85            issues.push(Issue {
86                line: number_field(message, "line").unwrap_or(0),
87                column: number_field(message, "column").unwrap_or(0),
88                severity: severity.to_string(),
89                message: string_field(message, "message").unwrap_or("").to_string(),
90                rule: string_field(message, "ruleId").map(ToString::to_string),
91            });
92        }
93        if !issues.is_empty() {
94            grouped.insert(path.to_string(), issues);
95        }
96    }
97
98    let total = errors + warnings;
99    if total == 0 {
100        return Some("eslint: no issues".to_string());
101    }
102
103    let mut lines = vec![format!(
104        "eslint: {total} issues ({errors} errors, {warnings} warnings)"
105    )];
106    append_grouped_issues(&mut lines, &grouped);
107    Some(lines.join("\n"))
108}
109
110fn compress_text(output: &str) -> Option<String> {
111    let mut grouped: BTreeMap<String, Vec<Issue>> = BTreeMap::new();
112    let mut current_file: Option<String> = None;
113    let mut summary = None;
114    let mut parsed_issues = 0usize;
115
116    for line in output.lines() {
117        let trimmed = line.trim();
118        if trimmed.is_empty() {
119            continue;
120        }
121        if is_summary_line(trimmed) {
122            summary = Some(trimmed.to_string());
123            continue;
124        }
125        if let Some((file, issue)) = parse_colon_issue(trimmed) {
126            grouped.entry(file).or_default().push(issue);
127            parsed_issues += 1;
128            continue;
129        }
130        if let Some(file) = current_file.as_deref() {
131            if let Some(issue) = parse_stylish_issue(trimmed) {
132                grouped.entry(file.to_string()).or_default().push(issue);
133                parsed_issues += 1;
134                continue;
135            }
136        }
137        if is_file_header(line) {
138            current_file = Some(trimmed.to_string());
139        }
140    }
141
142    if parsed_issues == 0 {
143        return summary;
144    }
145
146    let mut lines = Vec::new();
147    append_grouped_issues(&mut lines, &grouped);
148    if let Some(summary) = summary {
149        if !lines.is_empty() {
150            lines.push(String::new());
151        }
152        lines.push(summary);
153    }
154    Some(lines.join("\n"))
155}
156
157fn parse_colon_issue(line: &str) -> Option<(String, Issue)> {
158    let parts: Vec<&str> = line.splitn(4, ':').collect();
159    if parts.len() != 4 {
160        return None;
161    }
162    let line_number = parts.get(1)?.trim().parse().ok()?;
163    let column = parts.get(2)?.trim().parse().ok()?;
164    let (severity, message, rule) = parse_severity_message(parts.get(3)?.trim())?;
165    Some((
166        parts.first()?.trim().to_string(),
167        Issue {
168            line: line_number,
169            column,
170            severity,
171            message,
172            rule,
173        },
174    ))
175}
176
177fn parse_stylish_issue(line: &str) -> Option<Issue> {
178    let mut parts = line.split_whitespace();
179    let location = parts.next()?;
180    let (line_text, column_text) = location.split_once(':')?;
181    let line_number = line_text.parse().ok()?;
182    let column = column_text.parse().ok()?;
183    let severity = parts.next()?;
184    if !matches!(severity, "error" | "warning") {
185        return None;
186    }
187    let rest = parts.collect::<Vec<_>>().join(" ");
188    let (message, rule) = split_message_rule(&rest);
189    Some(Issue {
190        line: line_number,
191        column,
192        severity: severity.to_string(),
193        message,
194        rule,
195    })
196}
197
198fn parse_severity_message(rest: &str) -> Option<(String, String, Option<String>)> {
199    let mut parts = rest.split_whitespace();
200    let severity = parts.next()?;
201    if !matches!(severity, "error" | "warning") {
202        return None;
203    }
204    let rest = parts.collect::<Vec<_>>().join(" ");
205    let (message, rule) = split_message_rule(&rest);
206    Some((severity.to_string(), message, rule))
207}
208
209fn split_message_rule(rest: &str) -> (String, Option<String>) {
210    let Some((message, rule)) = rest.rsplit_once(' ') else {
211        return (rest.to_string(), None);
212    };
213    if looks_like_rule(rule) {
214        (message.trim_end().to_string(), Some(rule.to_string()))
215    } else {
216        (rest.to_string(), None)
217    }
218}
219
220fn looks_like_rule(token: &str) -> bool {
221    token.contains('/') || token.contains('-') || token.starts_with('@')
222}
223
224fn append_grouped_issues(lines: &mut Vec<String>, grouped: &BTreeMap<String, Vec<Issue>>) {
225    for (file, issues) in grouped {
226        lines.push(file.clone());
227        for issue in issues.iter().take(MAX_ISSUES_PER_FILE) {
228            let rule = issue.rule.as_deref().unwrap_or("unknown");
229            lines.push(format!(
230                "  {}:{} {} {} {}",
231                issue.line, issue.column, issue.severity, rule, issue.message
232            ));
233        }
234        if issues.len() > MAX_ISSUES_PER_FILE {
235            lines.push(format!(
236                "  +{} more issues in this file",
237                issues.len() - MAX_ISSUES_PER_FILE
238            ));
239        }
240    }
241}
242
243fn severity_name(value: Option<&Value>) -> &'static str {
244    match value.and_then(Value::as_u64) {
245        Some(2) => "error",
246        Some(1) => "warning",
247        _ => "info",
248    }
249}
250
251fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
252    value.get(key).and_then(Value::as_str)
253}
254
255fn number_field(value: &Value, key: &str) -> Option<usize> {
256    value
257        .get(key)
258        .and_then(Value::as_u64)
259        .and_then(|number| usize::try_from(number).ok())
260}
261
262fn is_summary_line(trimmed: &str) -> bool {
263    (trimmed.starts_with('✖') || trimmed.starts_with('✔'))
264        && (trimmed.contains(" problem") || trimmed.contains(" problems"))
265}
266
267fn is_file_header(line: &str) -> bool {
268    !line.starts_with(char::is_whitespace)
269        && !line.trim().contains(": ")
270        && (line.contains('/') || line.contains('\\') || line.contains('.'))
271}
272
273fn finish(input: &str) -> String {
274    let stripped = strip_ansi(input);
275    let deduped = dedup_consecutive(&stripped);
276    cap_lines(
277        &middle_truncate(&deduped, 24 * 1024, 12 * 1024, 12 * 1024),
278        MAX_LINES,
279    )
280}
281
282fn cap_lines(input: &str, max_lines: usize) -> String {
283    let lines: Vec<&str> = input.lines().collect();
284    if lines.len() <= max_lines {
285        return input.trim_end().to_string();
286    }
287    let mut kept = lines
288        .iter()
289        .take(max_lines)
290        .copied()
291        .collect::<Vec<_>>()
292        .join("\n");
293    kept.push_str(&format!(
294        "\n... truncated {} lines",
295        lines.len() - max_lines
296    ));
297    kept
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn matches_eslint_tokens_without_matching_npm_run_lint() {
306        let compressor = EslintCompressor;
307        assert!(compressor.matches("npx eslint src"));
308        assert!(compressor.matches("./node_modules/.bin/eslint src"));
309        assert!(!compressor.matches("npm run lint"));
310    }
311
312    #[test]
313    fn compresses_stylish_text_grouped_by_file() {
314        let output = r#"/repo/src/foo.js
315  1:10  error    'foo' is defined but never used  no-unused-vars
316  2:3   warning  Unexpected console statement      no-console
317
318/repo/src/bar.js
319  5:1  error  Missing semicolon  semi
320
321✖ 3 problems (2 errors, 1 warning)
322"#;
323
324        let compressed = compress_eslint(output);
325
326        assert!(compressed.contains("/repo/src/foo.js"));
327        assert!(compressed.contains("1:10 error no-unused-vars 'foo' is defined but never used"));
328        assert!(compressed.contains("✖ 3 problems (2 errors, 1 warning)"));
329    }
330
331    #[test]
332    fn compresses_colon_text_shape() {
333        let output = "src/foo.ts:4:12: error Unexpected any @typescript-eslint/no-explicit-any\n✖ 1 problem (1 error, 0 warnings)\n";
334
335        let compressed = compress_eslint(output);
336
337        assert!(compressed.contains("src/foo.ts"));
338        assert!(compressed.contains("4:12 error @typescript-eslint/no-explicit-any Unexpected any"));
339    }
340
341    #[test]
342    fn compresses_json_formatter_output() {
343        let output = r#"[{"filePath":"/repo/fullOfProblems.js","messages":[{"ruleId":"no-unused-vars","severity":2,"message":"'addOne' is defined but never used.","line":1,"column":10},{"ruleId":"semi","severity":1,"message":"Missing semicolon.","line":3,"column":20}],"errorCount":1,"warningCount":1}]"#;
344
345        let compressed = compress_eslint(output);
346
347        assert!(compressed.starts_with("eslint: 2 issues (1 errors, 1 warnings)"));
348        assert!(
349            compressed.contains("1:10 error no-unused-vars 'addOne' is defined but never used.")
350        );
351        assert!(compressed.contains("3:20 warning semi Missing semicolon."));
352    }
353
354    #[test]
355    fn malformed_json_falls_back_safely() {
356        let output = "[{not-json";
357
358        let compressed = compress_eslint(output);
359
360        assert_eq!(compressed, output);
361    }
362
363    #[test]
364    fn caps_large_text_output_per_file() {
365        let mut output = String::from("src/foo.js\n");
366        for index in 1..=12 {
367            output.push_str(&format!(
368                "  {index}:1  error  Problem number {index}  no-alert\n"
369            ));
370        }
371        output.push_str("✖ 12 problems (12 errors, 0 warnings)\n");
372
373        let compressed = compress_eslint(&output);
374
375        assert!(compressed.contains("+2 more issues in this file"));
376        assert!(!compressed.contains("Problem number 12  no-alert"));
377    }
378}