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