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