Skip to main content

aft/compress/
biome.rs

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}