Skip to main content

aft/compress/
prettier.rs

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