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