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}