1use std::collections::BTreeMap;
2
3use serde_json::Value;
4
5use crate::compress::generic::{dedup_consecutive, middle_truncate, strip_ansi, GenericCompressor};
6use crate::compress::{CompressionResult, Compressor};
7
8const MAX_LINES: usize = 200;
9const MAX_DIAGNOSTICS_PER_RULE: usize = 10;
10
11pub struct BiomeCompressor;
12
13#[derive(Clone, Debug)]
14struct Diagnostic {
15 file: String,
16 line: usize,
17 column: usize,
18 severity: String,
19 message: String,
20}
21
22impl Compressor for BiomeCompressor {
23 fn matches(&self, command: &str) -> bool {
24 command_tokens(command).any(|token| token == "biome")
25 }
26
27 fn compress_with_exit_code(
28 &self,
29 _command: &str,
30 output: &str,
31 _exit_code: Option<i32>,
32 ) -> CompressionResult {
33 compress_biome(output).into()
34 }
35
36 fn matches_output(&self, output: &str) -> bool {
37 output
38 .lines()
39 .any(|line| is_biome_output_rule_header(line.trim()))
40 || looks_like_biome_json_output(output)
41 }
42}
43
44fn looks_like_biome_json_output(output: &str) -> bool {
45 let trimmed = output.trim_start();
46 if !trimmed.starts_with('{') {
47 return false;
48 }
49
50 serde_json::from_str::<Value>(trimmed)
51 .ok()
52 .is_some_and(|value| value.get("diagnostics").is_some() || value.get("errors").is_some())
53}
54
55fn is_biome_output_rule_header(trimmed: &str) -> bool {
56 trimmed.contains('━')
57 && (trimmed.starts_with("lint/")
58 || trimmed.starts_with("assist/")
59 || trimmed.starts_with("format/"))
60}
61
62fn compress_biome(output: &str) -> String {
63 let trimmed = output.trim_start();
64 if trimmed.starts_with('{') {
65 if let Some(compressed) = compress_json(trimmed) {
66 return finish(&compressed);
67 }
68 return GenericCompressor::compress_output(output);
69 }
70
71 if let Some(compressed) = compress_text(output) {
72 return finish(&compressed);
73 }
74
75 GenericCompressor::compress_output(output)
76}
77
78fn command_tokens(command: &str) -> impl Iterator<Item = String> + '_ {
79 command
80 .split_whitespace()
81 .map(|token| token.trim_matches(|ch| matches!(ch, '\'' | '"')))
82 .filter(|token| !matches!(*token, "npx" | "pnpm" | "yarn" | "bun" | "bunx"))
83 .map(|token| {
84 token
85 .rsplit(['/', '\\'])
86 .next()
87 .unwrap_or(token)
88 .trim_end_matches(".cmd")
89 .to_string()
90 })
91}
92
93fn compress_json(input: &str) -> Option<String> {
94 let value: Value = serde_json::from_str(input).ok()?;
95 let diagnostics = diagnostics_array(&value)?;
96 let mut grouped: BTreeMap<String, Vec<Diagnostic>> = BTreeMap::new();
97
98 for diagnostic in diagnostics {
99 let rule = rule_name(diagnostic);
100 let parsed = Diagnostic {
101 file: diagnostic_file(diagnostic)
102 .unwrap_or("<unknown>")
103 .to_string(),
104 line: diagnostic_position(diagnostic, "line"),
105 column: diagnostic_position(diagnostic, "column"),
106 severity: diagnostic_severity(diagnostic),
107 message: diagnostic_message(diagnostic)
108 .unwrap_or("diagnostic")
109 .to_string(),
110 };
111 grouped.entry(rule).or_default().push(parsed);
112 }
113
114 if grouped.is_empty() {
115 return Some("biome: no diagnostics".to_string());
116 }
117
118 let total = grouped.values().map(Vec::len).sum::<usize>();
119 let mut lines = vec![format!("biome: {total} diagnostics")];
120 for (rule, diagnostics) in grouped {
121 lines.push(format!("{rule} ({})", diagnostics.len()));
122 for diagnostic in diagnostics.iter().take(MAX_DIAGNOSTICS_PER_RULE) {
123 lines.push(format!(
124 " {}:{}:{} {} {}",
125 diagnostic.file,
126 diagnostic.line,
127 diagnostic.column,
128 diagnostic.severity,
129 diagnostic.message
130 ));
131 }
132 if diagnostics.len() > MAX_DIAGNOSTICS_PER_RULE {
133 lines.push(format!(
134 " +{} more diagnostics for this rule",
135 diagnostics.len() - MAX_DIAGNOSTICS_PER_RULE
136 ));
137 }
138 }
139
140 Some(lines.join("\n"))
141}
142
143fn diagnostics_array(value: &Value) -> Option<&Vec<Value>> {
144 value
145 .get("diagnostics")
146 .and_then(Value::as_array)
147 .or_else(|| value.get("errors").and_then(Value::as_array))
148}
149
150fn rule_name(diagnostic: &Value) -> String {
151 string_field(diagnostic, "category")
152 .or_else(|| string_field(diagnostic, "rule"))
153 .or_else(|| diagnostic.pointer("/code/value").and_then(Value::as_str))
154 .or_else(|| string_field(diagnostic, "source"))
155 .unwrap_or("biome")
156 .to_string()
157}
158
159fn diagnostic_file(diagnostic: &Value) -> Option<&str> {
160 diagnostic
161 .pointer("/location/path")
162 .and_then(Value::as_str)
163 .or_else(|| diagnostic.pointer("/location/file").and_then(Value::as_str))
164 .or_else(|| {
165 diagnostic
166 .pointer("/location/sourceCode")
167 .and_then(Value::as_str)
168 })
169 .or_else(|| string_field(diagnostic, "file"))
170 .or_else(|| string_field(diagnostic, "filePath"))
171}
172
173fn diagnostic_position(diagnostic: &Value, field: &str) -> usize {
174 let pointer = format!("/location/range/start/{field}");
175 diagnostic
176 .pointer(&pointer)
177 .and_then(Value::as_u64)
178 .or_else(|| {
179 diagnostic
180 .pointer(&format!("/location/{field}"))
181 .and_then(Value::as_u64)
182 })
183 .and_then(|number| usize::try_from(number).ok())
184 .unwrap_or(0)
185}
186
187fn diagnostic_severity(diagnostic: &Value) -> String {
188 string_field(diagnostic, "severity")
189 .or_else(|| string_field(diagnostic, "level"))
190 .unwrap_or("error")
191 .to_string()
192}
193
194fn diagnostic_message(diagnostic: &Value) -> Option<&str> {
195 string_field(diagnostic, "description")
196 .or_else(|| string_field(diagnostic, "message"))
197 .or_else(|| diagnostic.pointer("/message/text").and_then(Value::as_str))
198}
199
200fn compress_text(output: &str) -> Option<String> {
201 let lines: Vec<&str> = output.lines().collect();
202 let mut result = Vec::new();
203 let mut summaries = Vec::new();
204 let mut diagnostics = 0usize;
205 let mut index = 0usize;
206
207 while index < lines.len() {
208 let line = lines[index];
209 let trimmed = line.trim();
210 if is_summary_line(trimmed) {
211 summaries.push(line.to_string());
212 index += 1;
213 continue;
214 }
215 if is_progress_line(trimmed) {
216 index += 1;
217 continue;
218 }
219 if is_location_header(trimmed) || is_rule_header(trimmed) {
220 diagnostics += 1;
221 let start = index;
222 index += 1;
223 while index < lines.len() {
224 let current = lines[index].trim();
225 if is_location_header(current)
226 || is_rule_header(current)
227 || is_summary_line(current)
228 {
229 break;
230 }
231 index += 1;
232 }
233 result.extend(trim_diagnostic_block(&lines[start..index]));
234 continue;
235 }
236 index += 1;
237 }
238
239 if diagnostics == 0 && summaries.is_empty() {
240 return None;
241 }
242 if !summaries.is_empty() {
243 if !result.is_empty() {
244 result.push(String::new());
245 }
246 result.extend(summaries);
247 }
248
249 Some(result.join("\n"))
250}
251
252fn trim_diagnostic_block(block: &[&str]) -> Vec<String> {
253 let mut result = Vec::new();
254 let mut context_lines = 0usize;
255 for line in block {
256 let trimmed = line.trim_start();
257 if is_source_context_line(trimmed) {
258 context_lines += 1;
259 if context_lines > 3 {
260 continue;
261 }
262 }
263 result.push((*line).to_string());
264 }
265 result
266}
267
268fn is_source_context_line(trimmed: &str) -> bool {
269 trimmed.starts_with('>') || trimmed.starts_with('│') || trimmed.starts_with('|')
270}
271
272fn is_location_header(trimmed: &str) -> bool {
273 let Some((before_col, _after_col)) = trimmed.rsplit_once(':') else {
274 return false;
275 };
276 let Some((path, line_number)) = before_col.rsplit_once(':') else {
277 return false;
278 };
279 !path.is_empty()
280 && !line_number.is_empty()
281 && line_number.chars().all(|char| char.is_ascii_digit())
282 && trimmed
283 .rsplit_once(':')
284 .is_some_and(|(_, column)| column.chars().all(|char| char.is_ascii_digit()))
285}
286
287fn is_rule_header(trimmed: &str) -> bool {
288 trimmed.contains('━')
289 && (trimmed.starts_with("lint/")
290 || trimmed.starts_with("assist/")
291 || trimmed.starts_with("parse")
292 || trimmed.starts_with("format"))
293}
294
295fn is_summary_line(trimmed: &str) -> bool {
296 trimmed.starts_with("Found ")
297 || (trimmed.starts_with("Checked ") && trimmed.contains("No fixes applied"))
298 || trimmed.starts_with("Skipped ")
299 || trimmed.starts_with("Fixed ")
300 || trimmed.contains("No fixes applied")
301}
302
303fn is_progress_line(trimmed: &str) -> bool {
304 trimmed.starts_with("Checked ") && !trimmed.contains("No fixes applied")
305}
306
307fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
308 value.get(key).and_then(Value::as_str)
309}
310
311fn finish(input: &str) -> String {
312 let stripped = strip_ansi(input);
313 let deduped = dedup_consecutive(&stripped);
314 cap_lines(
315 &middle_truncate(&deduped, 24 * 1024, 12 * 1024, 12 * 1024),
316 MAX_LINES,
317 )
318}
319
320fn cap_lines(input: &str, max_lines: usize) -> String {
321 let lines: Vec<&str> = input.lines().collect();
322 if lines.len() <= max_lines {
323 return input.trim_end().to_string();
324 }
325 let mut kept = lines
326 .iter()
327 .take(max_lines)
328 .copied()
329 .collect::<Vec<_>>()
330 .join("\n");
331 kept.push_str(&format!(
332 "\n... truncated {} lines",
333 lines.len() - max_lines
334 ));
335 kept
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn matches_biome_token() {
344 let compressor = BiomeCompressor;
345 assert!(compressor.matches("npx biome check ."));
346 assert!(compressor.matches("./node_modules/.bin/biome lint"));
347 assert!(!compressor.matches("npm run check"));
348 }
349
350 #[test]
351 fn keeps_passing_summary() {
352 let output = "Checked 12 files in 35ms. No fixes applied.\n";
353
354 let compressed = compress_biome(output);
355
356 assert_eq!(compressed, "Checked 12 files in 35ms. No fixes applied.");
357 }
358
359 #[test]
360 fn compresses_text_diagnostic_blocks() {
361 let output = r#"Checked 1 file in 2ms
362src/main.ts:1:1
363lint/suspicious/noConsole ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
364
365 ✖ Don't use console.
366
367 > 1 │ console.log("debug")
368 │ ^^^^^^^^^^^
369 2 │ export {}
370
371 i Remove the console statement.
372
373Found 1 error.
374"#;
375
376 let compressed = compress_biome(output);
377
378 assert!(compressed.contains("src/main.ts:1:1"));
379 assert!(compressed.contains("lint/suspicious/noConsole"));
380 assert!(compressed.contains("Found 1 error."));
381 assert!(!compressed.contains("Checked 1 file in 2ms"));
382 }
383
384 #[test]
385 fn compresses_json_reporter_output() {
386 let output = r#"{"diagnostics":[{"category":"lint/suspicious/noConsole","severity":"warning","description":"Don't use console.","location":{"path":"src/main.ts","range":{"start":{"line":1,"column":1}}}},{"category":"lint/suspicious/noConsole","severity":"warning","description":"Don't use console again.","location":{"path":"src/other.ts","range":{"start":{"line":2,"column":3}}}},{"category":"assist/source/organizeImports","severity":"error","description":"The imports and exports are not sorted.","location":{"path":"src/main.ts","range":{"start":{"line":1,"column":1}}}}]}"#;
387
388 let compressed = compress_biome(output);
389
390 assert!(compressed.starts_with("biome: 3 diagnostics"));
391 assert!(compressed.contains("lint/suspicious/noConsole (2)"));
392 assert!(compressed.contains("src/main.ts:1:1 warning Don't use console."));
393 }
394
395 #[test]
396 fn malformed_json_falls_back_safely() {
397 let output = "{not-json";
398
399 let compressed = compress_biome(output);
400
401 assert_eq!(compressed, output);
402 }
403
404 #[test]
405 fn caps_large_json_per_rule() {
406 let diagnostics = (1..=12)
407 .map(|line| {
408 format!(
409 r#"{{"category":"lint/suspicious/noConsole","severity":"warning","description":"Diagnostic {line}","location":{{"path":"src/main.ts","range":{{"start":{{"line":{line},"column":1}}}}}}}}"#
410 )
411 })
412 .collect::<Vec<_>>()
413 .join(",");
414 let output = format!(r#"{{"diagnostics":[{diagnostics}]}}"#);
415
416 let compressed = compress_biome(&output);
417
418 assert!(compressed.contains("+2 more diagnostics for this rule"));
419 assert!(!compressed.contains("Diagnostic 12"));
420 }
421}