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