Skip to main content

aft/compress/
prettier.rs

1use crate::compress::generic::{dedup_consecutive, middle_truncate, strip_ansi, GenericCompressor};
2use crate::compress::{CompressionResult, Compressor};
3
4const MAX_LINES: usize = 250;
5
6pub struct PrettierCompressor;
7
8impl Compressor for PrettierCompressor {
9    fn matches(&self, command: &str) -> bool {
10        command_tokens(command).any(|token| token == "prettier")
11    }
12
13    fn compress_with_exit_code(
14        &self,
15        _command: &str,
16        output: &str,
17        exit_code: Option<i32>,
18    ) -> CompressionResult {
19        let compressed = compress_prettier(output);
20        if matches!(exit_code, Some(code) if code != 0)
21            && compressed.starts_with("prettier: formatted")
22        {
23            GenericCompressor::compress_output(output).into()
24        } else {
25            compressed.into()
26        }
27    }
28
29    fn matches_output(&self, output: &str) -> bool {
30        looks_like_prettier_check_output(output)
31    }
32}
33
34fn looks_like_prettier_check_output(output: &str) -> bool {
35    let mut has_checking = false;
36    let mut has_warn = false;
37    for line in output.lines() {
38        let trimmed = line.trim_start();
39        has_checking |= trimmed == "Checking formatting...";
40        has_warn |= trimmed.starts_with("[warn] ");
41        if trimmed.starts_with("Code style issues found") {
42            return true;
43        }
44    }
45    has_checking && has_warn
46}
47
48fn compress_prettier(output: &str) -> String {
49    let mut kept = Vec::new();
50    let mut formatted = 0usize;
51    let mut saw_diagnostic = false;
52
53    for line in output.lines() {
54        let trimmed = line.trim();
55        if trimmed.is_empty() || trimmed == "Checking formatting..." {
56            continue;
57        }
58
59        if trimmed.starts_with("[error]") {
60            saw_diagnostic = true;
61            kept.push(line.to_string());
62            continue;
63        }
64
65        if trimmed.starts_with("[warn]") {
66            saw_diagnostic = true;
67            kept.push(line.to_string());
68            continue;
69        }
70
71        if is_code_style_summary(trimmed) {
72            saw_diagnostic = true;
73            kept.push(line.to_string());
74            continue;
75        }
76
77        if is_success_duration_line(trimmed) {
78            if !trimmed.contains("(unchanged)") {
79                formatted += 1;
80            }
81            continue;
82        }
83
84        kept.push(line.to_string());
85    }
86
87    if kept.is_empty() && (formatted > 0 || output.trim().is_empty()) {
88        return format!("prettier: formatted {formatted} files");
89    }
90
91    if !saw_diagnostic && formatted > 0 && kept.is_empty() {
92        return format!("prettier: formatted {formatted} files");
93    }
94
95    if kept.is_empty() {
96        return format!("prettier: formatted {formatted} files");
97    }
98
99    finish(&kept.join("\n"))
100}
101
102fn command_tokens(command: &str) -> impl Iterator<Item = String> + '_ {
103    command
104        .split_whitespace()
105        .map(|token| token.trim_matches(|ch| matches!(ch, '\'' | '"')))
106        .filter(|token| !matches!(*token, "npx" | "pnpm" | "yarn" | "bun" | "bunx" | "exec"))
107        .map(|token| {
108            token
109                .rsplit(['/', '\\'])
110                .next()
111                .unwrap_or(token)
112                .trim_end_matches(".cmd")
113                .to_string()
114        })
115}
116
117fn is_code_style_summary(trimmed: &str) -> bool {
118    trimmed.contains("Code style issues found")
119}
120
121fn is_success_duration_line(trimmed: &str) -> bool {
122    let Some((_, tail)) = trimmed.rsplit_once(' ') else {
123        return false;
124    };
125    let duration = tail
126        .strip_suffix("ms")
127        .or_else(|| tail.strip_suffix("ms (unchanged)"));
128    if duration.is_some_and(|value| value.chars().all(|ch| ch.is_ascii_digit() || ch == '.')) {
129        return true;
130    }
131
132    trimmed.ends_with("ms (unchanged)")
133        && trimmed
134            .rsplit_once(' ')
135            .map(|(_, suffix)| suffix == "(unchanged)")
136            .unwrap_or(false)
137}
138
139fn finish(input: &str) -> String {
140    let stripped = strip_ansi(input);
141    let deduped = dedup_consecutive(&stripped);
142    cap_lines(
143        &middle_truncate(&deduped, 32 * 1024, 16 * 1024, 16 * 1024),
144        MAX_LINES,
145    )
146}
147
148fn cap_lines(input: &str, max_lines: usize) -> String {
149    let lines: Vec<&str> = input.lines().collect();
150    if lines.len() <= max_lines {
151        return input.trim_end().to_string();
152    }
153    let mut kept = lines
154        .iter()
155        .take(max_lines)
156        .copied()
157        .collect::<Vec<_>>()
158        .join("\n");
159    kept.push_str(&format!(
160        "\n... truncated {} lines",
161        lines.len() - max_lines
162    ));
163    kept
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn matches_prettier_invocations() {
172        let compressor = PrettierCompressor;
173        assert!(compressor.matches("prettier --write src/"));
174        assert!(compressor.matches("npx prettier --check"));
175        assert!(compressor.matches("pnpm exec prettier"));
176        assert!(compressor.matches("./node_modules/.bin/prettier --write ."));
177        assert!(!compressor.matches("cargo build"));
178        assert!(!compressor.matches("ls prettier.config.js"));
179    }
180
181    #[test]
182    fn compresses_real_clean_format_pass() {
183        let output = "src/foo.ts 25ms\nsrc/bar.ts 42ms (unchanged)\nsrc/baz.ts 18ms\n";
184        let compressed = compress_prettier(output);
185        assert_eq!(compressed, "prettier: formatted 2 files");
186        assert!(!compressed.contains("src/foo.ts 25ms"));
187        assert!(!compressed.contains("unchanged"));
188        assert!(compressed.len() < output.len());
189    }
190
191    #[test]
192    fn preserves_error_blocks_verbatim() {
193        let output = "src/foo.ts 25ms\n[error] src/broken.ts: SyntaxError: Unexpected token (5:3)\n[error]   3 |\n[error] > 5 |   const x = ;\n[error]     |             ^\n";
194        let compressed = compress_prettier(output);
195        assert!(compressed.contains("[error] src/broken.ts: SyntaxError: Unexpected token (5:3)"));
196        assert!(compressed.contains("[error]   3 |"));
197        assert!(compressed.contains("[error] > 5 |   const x = ;"));
198        assert!(compressed.contains("[error]     |             ^"));
199        assert!(!compressed.contains("src/foo.ts 25ms"));
200    }
201
202    #[test]
203    fn preserves_check_mode_warnings_and_summary() {
204        let output = "Checking formatting...\n[warn] src/a.ts\n[warn] src/b.tsx\n[warn] Code style issues found in 2 files. Run Prettier with --write to fix.\n";
205        let compressed = compress_prettier(output);
206        assert!(compressed.contains("[warn] src/a.ts"));
207        assert!(compressed.contains("[warn] src/b.tsx"));
208        assert!(compressed.contains("Code style issues found in 2 files"));
209        assert!(!compressed.contains("Checking formatting"));
210    }
211
212    #[test]
213    fn compresses_large_success_input() {
214        let mut output = String::new();
215        for index in 0..500 {
216            output.push_str(&format!("src/file{index}.ts {}ms\n", index + 1));
217        }
218        let compressed = compress_prettier(&output);
219        assert!(compressed.contains("prettier: formatted 500 files"));
220        assert!(compressed.len() < output.len() / 2);
221        assert!(!compressed.contains("src/file499.ts"));
222    }
223}