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}