Skip to main content

aft/compress/
prettier.rs

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