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