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