1use std::collections::BTreeMap;
2
3use crate::compress::{generic::GenericCompressor, CompressionResult, Compressor};
4
5pub struct TscCompressor;
6
7const MAX_BUFFERED_ERRORS_PER_FILE: usize = 30;
11
12#[derive(Default)]
13struct FileErrors<'a> {
14 count: usize,
15 buffered: Vec<&'a str>,
16}
17
18impl<'a> FileErrors<'a> {
19 fn push(&mut self, line: &'a str) {
20 self.count += 1;
21 if self.buffered.len() < MAX_BUFFERED_ERRORS_PER_FILE {
22 self.buffered.push(line);
23 }
24 }
25}
26
27impl Compressor for TscCompressor {
28 fn matches(&self, command: &str) -> bool {
29 command.split_whitespace().any(|token| token == "tsc")
30 }
31
32 fn compress_with_exit_code(
33 &self,
34 _command: &str,
35 output: &str,
36 exit_code: Option<i32>,
37 ) -> CompressionResult {
38 let compressed = compress_tsc(output);
39 if matches!(exit_code, Some(code) if code != 0) && compressed == "No errors. [cmpaft]" {
40 GenericCompressor::compress_output(output).into()
41 } else {
42 compressed.into()
43 }
44 }
45
46 fn matches_output(&self, output: &str) -> bool {
47 output
48 .lines()
49 .any(|line| is_tsc_error_line(line) || is_tsc_top_level_error_line(line))
50 }
51}
52
53fn compress_tsc(output: &str) -> String {
54 let mut by_file: BTreeMap<&str, FileErrors<'_>> = BTreeMap::new();
55 let mut ungrouped = Vec::new();
56 let mut summary = None;
57
58 for line in output.lines() {
59 if let Some(file) = error_file(line) {
60 by_file.entry(file).or_default().push(line);
61 } else if is_tsc_top_level_error_line(line) {
62 ungrouped.push(line);
63 }
64 if is_tsc_summary(line) {
65 summary = Some(line);
66 }
67 }
68
69 if by_file.is_empty() && ungrouped.is_empty() {
70 if output_is_likely_success(output) {
71 return "No errors. [cmpaft]".to_string();
72 }
73
74 return GenericCompressor::compress_output(output);
75 }
76
77 let mut result = String::new();
78 let mut emitted_files = 0usize;
79 for errors in by_file.values() {
80 if emitted_files >= 10 && by_file.len() > 20 {
81 continue;
82 }
83 emitted_files += 1;
84 if errors.count > MAX_BUFFERED_ERRORS_PER_FILE {
85 for error in errors.buffered.iter().take(10) {
86 push_output_line(&mut result, error);
87 }
88 push_output_line(
89 &mut result,
90 &format!("... and {} more errors in this file", errors.count - 10),
91 );
92 } else {
93 for error in &errors.buffered {
94 push_output_line(&mut result, error);
95 }
96 }
97 }
98
99 for error in ungrouped {
100 push_output_line(&mut result, error);
101 }
102 if by_file.len() > 20 {
103 push_output_line(
104 &mut result,
105 &format!(
106 "... and {} more files with errors",
107 by_file.len() - emitted_files
108 ),
109 );
110 }
111 if let Some(summary) = summary {
112 push_output_line(&mut result, summary);
113 }
114
115 result
116}
117
118fn is_tsc_error_line(line: &str) -> bool {
119 error_file(line).is_some()
120}
121
122fn is_tsc_top_level_error_line(line: &str) -> bool {
123 let trimmed = line.trim_start();
124 trimmed.starts_with("error TS")
125 && trimmed["error TS".len()..]
126 .chars()
127 .next()
128 .is_some_and(|char| char.is_ascii_digit())
129}
130
131fn output_is_likely_success(output: &str) -> bool {
132 let trimmed = output.trim();
133 trimmed.is_empty()
134 || trimmed
135 .lines()
136 .any(|line| line.trim().contains("Found 0 errors"))
137}
138
139fn error_file(line: &str) -> Option<&str> {
140 let marker = line.find("): error TS")?;
141 let before = &line[..marker];
142 let open = before.rfind('(')?;
143 if before[open + 1..]
144 .split(',')
145 .all(|part| !part.is_empty() && part.chars().all(|char| char.is_ascii_digit()))
146 {
147 Some(&before[..open])
148 } else {
149 None
150 }
151}
152
153fn is_tsc_summary(line: &str) -> bool {
154 let trimmed = line.trim();
155 trimmed.starts_with("Found ") && trimmed.contains(" errors") && trimmed.contains(" files")
156}
157
158fn push_output_line(output: &mut String, line: &str) {
159 if !output.is_empty() {
160 output.push('\n');
161 }
162 output.push_str(line.trim_end());
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn bounded_grouping_matches_frozen_compressor_output() {
171 let mut output = String::new();
172 for file in (0..25).rev() {
173 for diagnostic in 0..35 {
174 output.push_str(&format!(
175 "src/generated/module_{file:02}.ts({diagnostic},17): error TS2322: detail {diagnostic} \n"
176 ));
177 }
178 }
179 output.push_str("error TS18003: No inputs were found. \n");
180 output.push_str("Found 876 errors in 25 files. \n");
181
182 assert_eq!(compress_tsc(&output), frozen_compress_tsc(&output));
183 }
184
185 #[test]
186 fn per_file_storage_stops_at_detailed_output_threshold() {
187 let mut errors = FileErrors::default();
188 let line = "src/index.ts(1,1): error TS2322: detail";
189 for _ in 0..10_000 {
190 errors.push(line);
191 }
192
193 assert_eq!(errors.count, 10_000);
194 assert_eq!(errors.buffered.len(), MAX_BUFFERED_ERRORS_PER_FILE);
195 }
196
197 fn frozen_compress_tsc(output: &str) -> String {
198 let lines: Vec<&str> = output.lines().collect();
199 let error_lines: Vec<&str> = lines
200 .iter()
201 .copied()
202 .filter(|line| is_tsc_error_line(line) || is_tsc_top_level_error_line(line))
203 .collect();
204
205 if error_lines.is_empty() {
206 if output_is_likely_success(output) {
207 return "No errors. [cmpaft]".to_string();
208 }
209 return GenericCompressor::compress_output(output);
210 }
211
212 let mut by_file: BTreeMap<String, Vec<String>> = BTreeMap::new();
213 let mut ungrouped = Vec::new();
214 for line in error_lines {
215 if let Some(file) = error_file(line) {
216 by_file
217 .entry(file.to_string())
218 .or_default()
219 .push(line.to_string());
220 } else {
221 ungrouped.push(line.to_string());
222 }
223 }
224
225 let mut result = Vec::new();
226 let mut emitted_files = 0usize;
227 for errors in by_file.values() {
228 if emitted_files >= 10 && by_file.len() > 20 {
229 continue;
230 }
231 emitted_files += 1;
232 if errors.len() > 30 {
233 result.extend(errors.iter().take(10).cloned());
234 result.push(format!(
235 "... and {} more errors in this file",
236 errors.len() - 10
237 ));
238 } else {
239 result.extend(errors.iter().cloned());
240 }
241 }
242
243 result.extend(ungrouped);
244 if by_file.len() > 20 {
245 result.push(format!(
246 "... and {} more files with errors",
247 by_file.len() - emitted_files
248 ));
249 }
250 if let Some(summary) = lines.iter().rev().find(|line| is_tsc_summary(line)) {
251 result.push((*summary).to_string());
252 }
253
254 result
255 .join("\n")
256 .lines()
257 .map(str::trim_end)
258 .collect::<Vec<_>>()
259 .join("\n")
260 }
261}