1use std::collections::BTreeMap;
2
3use crate::compress::{generic::GenericCompressor, CompressionResult, Compressor};
4
5pub struct TscCompressor;
6
7impl Compressor for TscCompressor {
8 fn matches(&self, command: &str) -> bool {
9 command.split_whitespace().any(|token| token == "tsc")
10 }
11
12 fn compress(&self, _command: &str, output: &str) -> CompressionResult {
13 compress_tsc(output).into()
14 }
15
16 fn matches_output(&self, output: &str) -> bool {
17 output
18 .lines()
19 .any(|line| is_tsc_error_line(line) || is_tsc_top_level_error_line(line))
20 }
21}
22
23fn compress_tsc(output: &str) -> String {
24 let lines: Vec<&str> = output.lines().collect();
25 let error_lines: Vec<&str> = lines
26 .iter()
27 .copied()
28 .filter(|line| is_tsc_error_line(line) || is_tsc_top_level_error_line(line))
29 .collect();
30
31 if error_lines.is_empty() {
32 if output_is_likely_success(output) {
33 return "No errors. [cmpaft]".to_string();
34 }
35
36 return GenericCompressor::compress_output(output);
37 }
38
39 let mut by_file: BTreeMap<String, Vec<String>> = BTreeMap::new();
40 let mut ungrouped = Vec::new();
41 for line in error_lines {
42 if let Some(file) = error_file(line) {
43 by_file.entry(file).or_default().push(line.to_string());
44 } else {
45 ungrouped.push(line.to_string());
46 }
47 }
48
49 let mut result = Vec::new();
50 let mut emitted_files = 0usize;
51 for errors in by_file.values() {
52 if emitted_files >= 10 && by_file.len() > 20 {
53 continue;
54 }
55 emitted_files += 1;
56 if errors.len() > 30 {
57 result.extend(errors.iter().take(10).cloned());
58 result.push(format!(
59 "... and {} more errors in this file",
60 errors.len() - 10
61 ));
62 } else {
63 result.extend(errors.iter().cloned());
64 }
65 }
66
67 result.extend(ungrouped);
68 if by_file.len() > 20 {
69 result.push(format!(
70 "... and {} more files with errors",
71 by_file.len() - emitted_files
72 ));
73 }
74 if let Some(summary) = lines.iter().rev().find(|line| is_tsc_summary(line)) {
75 result.push((*summary).to_string());
76 }
77
78 trim_trailing_lines(&result.join("\n"))
79}
80
81fn is_tsc_error_line(line: &str) -> bool {
82 line.contains(": error TS") && error_file(line).is_some()
83}
84
85fn is_tsc_top_level_error_line(line: &str) -> bool {
86 let trimmed = line.trim_start();
87 trimmed.starts_with("error TS")
88 && trimmed["error TS".len()..]
89 .chars()
90 .next()
91 .is_some_and(|char| char.is_ascii_digit())
92}
93
94fn output_is_likely_success(output: &str) -> bool {
95 let trimmed = output.trim();
96 trimmed.is_empty()
97 || trimmed
98 .lines()
99 .any(|line| line.trim().contains("Found 0 errors"))
100}
101
102fn error_file(line: &str) -> Option<String> {
103 let marker = line.find("): error TS")?;
104 let before = &line[..marker];
105 let open = before.rfind('(')?;
106 if before[open + 1..]
107 .split(',')
108 .all(|part| !part.is_empty() && part.chars().all(|char| char.is_ascii_digit()))
109 {
110 Some(before[..open].to_string())
111 } else {
112 None
113 }
114}
115
116fn is_tsc_summary(line: &str) -> bool {
117 let trimmed = line.trim();
118 trimmed.starts_with("Found ") && trimmed.contains(" errors") && trimmed.contains(" files")
119}
120
121fn trim_trailing_lines(input: &str) -> String {
122 input
123 .lines()
124 .map(str::trim_end)
125 .collect::<Vec<_>>()
126 .join("\n")
127}