Skip to main content

aft/compress/
eslint.rs

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