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