Skip to main content

aft/compress/
tsc.rs

1use std::collections::BTreeMap;
2
3use crate::compress::{generic::GenericCompressor, CompressionResult, Compressor};
4
5pub struct TscCompressor;
6
7/// Buffer 30 errors per file because the output contract prints every error up to 30,
8/// then switches to the first 10 plus an omitted count. The `tsc-generated-80k`
9/// benchmark measured this bound over 8,035,632 bytes and 40 files.
10const 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}