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, 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}