1use std::path::PathBuf;
2
3use termesh_core::{Problem, ProblemSeverity};
4
5use crate::{DecodedTaskOutput, TaskOutputDecoder};
6
7pub struct TextProblemDecoder {
8 cwd: PathBuf,
9 pending: Vec<u8>,
10}
11
12impl TextProblemDecoder {
13 pub fn new(cwd: PathBuf) -> Self {
14 Self { cwd, pending: Vec::new() }
15 }
16}
17
18impl TaskOutputDecoder for TextProblemDecoder {
19 fn push(&mut self, bytes: &[u8]) -> DecodedTaskOutput {
20 self.pending.extend_from_slice(bytes);
21 let mut problems = Vec::new();
22 while let Some(end) = self.pending.iter().position(|byte| *byte == b'\n') {
23 let line: Vec<u8> = self.pending.drain(..=end).collect();
24 if let Some(problem) = decode_line(&self.cwd, &line) {
25 problems.push(problem);
26 }
27 }
28 DecodedTaskOutput { display: bytes.to_vec(), problems }
29 }
30
31 fn finish(&mut self) -> DecodedTaskOutput {
32 let line = std::mem::take(&mut self.pending);
33 let problems = decode_line(&self.cwd, &line).into_iter().collect();
34 DecodedTaskOutput { display: Vec::new(), problems }
35 }
36}
37
38fn decode_line(cwd: &std::path::Path, line: &[u8]) -> Option<Problem> {
39 let text = std::str::from_utf8(line).ok()?.trim_end_matches(['\r', '\n']);
40 let (text, prefixed_severity) = strip_maven_prefix(text);
41 let mut problem = tsc_problem(cwd, text)
42 .or_else(|| gcc_problem(cwd, text))
43 .or_else(|| javac_problem(cwd, text))
44 .or_else(|| python_problem(cwd, text))?;
45 if let Some(severity) = prefixed_severity {
46 problem.severity = severity;
47 }
48 Some(problem)
49}
50
51fn strip_maven_prefix(text: &str) -> (&str, Option<ProblemSeverity>) {
52 if let Some(text) = text.strip_prefix("[ERROR]") {
53 (text.trim_start(), Some(ProblemSeverity::Error))
54 } else if let Some(text) = text.strip_prefix("[WARNING]") {
55 (text.trim_start(), Some(ProblemSeverity::Warning))
56 } else {
57 (text, None)
58 }
59}
60
61fn tsc_problem(cwd: &std::path::Path, text: &str) -> Option<Problem> {
62 let coordinates_end = text.rfind("):")?;
63 let coordinates_start = text[..coordinates_end].rfind('(')?;
64 let mut coordinates = text[coordinates_start + 1..coordinates_end].split(',');
65 let line = coordinates.next()?.trim().parse().ok()?;
66 let column = coordinates.next()?.trim().parse().ok()?;
67 if coordinates.next().is_some() {
68 return None;
69 }
70 build_problem(cwd, &text[..coordinates_start], line, column, text[coordinates_end + 2..].trim())
71}
72
73fn gcc_problem(cwd: &std::path::Path, text: &str) -> Option<Problem> {
74 for (line_separator, _) in text.match_indices(':') {
75 let after_line = &text[line_separator + 1..];
76 let Some(column_separator) = after_line.find(':') else { continue };
77 let Ok(line) = after_line[..column_separator].trim().parse() else { continue };
78 let after_column = &after_line[column_separator + 1..];
79 let Some(message_separator) = after_column.find(':') else { continue };
80 let Ok(column) = after_column[..message_separator].trim().parse() else { continue };
81 return build_problem(
82 cwd,
83 text[..line_separator].trim(),
84 line,
85 column,
86 after_column[message_separator + 1..].trim(),
87 );
88 }
89 None
90}
91
92fn javac_problem(cwd: &std::path::Path, text: &str) -> Option<Problem> {
93 for (line_separator, _) in text.match_indices(':') {
94 let after_line = &text[line_separator + 1..];
95 let Some(message_separator) = after_line.find(':') else { continue };
96 let Ok(line) = after_line[..message_separator].trim().parse() else { continue };
97 let raw_path = text[..line_separator].trim();
98 if !plausible_two_part_path(raw_path) {
99 continue;
100 }
101 return build_problem(cwd, raw_path, line, 1, after_line[message_separator + 1..].trim());
102 }
103 None
104}
105
106fn plausible_two_part_path(raw_path: &str) -> bool {
107 !raw_path.is_empty()
108 && std::path::Path::new(raw_path).extension().is_some()
109 && (raw_path.contains('/') || raw_path.contains('\\') || raw_path.contains('.'))
110}
111
112fn python_problem(cwd: &std::path::Path, text: &str) -> Option<Problem> {
113 let frame = text.trim_start().strip_prefix("File \"")?;
114 let marker = "\", line ";
115 let path_end = frame.find(marker)?;
116 let tail = &frame[path_end + marker.len()..];
117 let line_text = tail.split([',', ' ']).next()?;
118 let line = line_text.parse().ok()?;
119 build_problem(cwd, &frame[..path_end], line, 1, text.trim())
120}
121
122fn build_problem(
123 cwd: &std::path::Path,
124 raw_path: &str,
125 line: usize,
126 column: usize,
127 message: &str,
128) -> Option<Problem> {
129 use std::path::Component;
130
131 if raw_path.is_empty() || message.is_empty() {
132 return None;
133 }
134 let path = PathBuf::from(raw_path);
135 if path.components().any(|component| component == Component::ParentDir) {
136 return None;
137 }
138 let path = if path.is_absolute() { path } else { cwd.join(path) };
139 let severity = if message
142 .split(|c: char| !c.is_ascii_alphabetic())
143 .find(|word| !word.is_empty())
144 .is_some_and(|word| {
145 word.eq_ignore_ascii_case("warning") || word.eq_ignore_ascii_case("warn")
146 }) {
147 ProblemSeverity::Warning
148 } else {
149 ProblemSeverity::Error
150 };
151 Some(Problem { path, line, column, severity, message: message.to_string() })
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use std::path::Path;
158 use termesh_core::ProblemSeverity;
159
160 #[test]
161 fn it_matches_the_tsc_shape() {
162 let mut decoder = TextProblemDecoder::new("/p".into());
163 let out = decoder.push(b"src/app.ts(12,5): error TS2304: Cannot find name 'foo'\n");
164 let problem = &out.problems[0];
165 assert_eq!(problem.path, Path::new("/p/src/app.ts"));
166 assert_eq!((problem.line, problem.column), (12, 5));
167 assert_eq!(problem.severity, ProblemSeverity::Error);
168 assert!(problem.message.contains("Cannot find name"));
169 }
170
171 #[test]
172 fn an_error_mentioning_the_word_warning_stays_an_error() {
173 let mut decoder = TextProblemDecoder::new("/p".into());
176 let out =
177 decoder.push(b"src/app.ts(3,1): error TS2531: suppress this with a warning comment\n");
178 assert_eq!(out.problems[0].severity, ProblemSeverity::Error);
179 }
180
181 #[test]
182 fn it_matches_the_gcc_shape() {
183 let mut decoder = TextProblemDecoder::new("/p".into());
184 let out = decoder.push(b"src/app.js:12:5: warning: unused variable\n");
185 assert_eq!(out.problems[0].severity, ProblemSeverity::Warning);
186 }
187
188 #[test]
189 fn it_matches_the_javac_shape() {
190 let mut decoder = TextProblemDecoder::new("/p".into());
193 let out =
194 decoder.push(b"src/main/java/com/example/App.java:12: error: cannot find symbol\n");
195 let problem = &out.problems[0];
196 assert_eq!(problem.path, Path::new("/p/src/main/java/com/example/App.java"));
197 assert_eq!(problem.line, 12);
198 assert_eq!(problem.column, 1, "no column in the output; default to the line start");
199 assert_eq!(problem.severity, ProblemSeverity::Error);
200 assert!(problem.message.contains("cannot find symbol"));
201 }
202
203 #[test]
204 fn a_three_part_line_still_matches_as_three_parts() {
205 let mut decoder = TextProblemDecoder::new("/p".into());
208 let out = decoder.push(b"src/app.js:12:5: error: broken\n");
209 assert_eq!((out.problems[0].line, out.problems[0].column), (12, 5));
210 }
211
212 #[test]
213 fn a_maven_prefixed_javac_line_still_resolves_its_path() {
214 let mut decoder = TextProblemDecoder::new("/p".into());
215 let out = decoder.push(b"[ERROR] src/main/java/App.java:7: error: ';' expected\n");
216 assert_eq!(out.problems[0].path, Path::new("/p/src/main/java/App.java"));
217 assert_eq!(out.problems[0].line, 7);
218 }
219
220 #[test]
221 fn a_maven_warning_prefix_is_authoritative() {
222 let mut decoder = TextProblemDecoder::new("/p".into());
223 let out = decoder.push(b"[WARNING] src/main/java/App.java:9: error: deprecated API\n");
224 assert_eq!(out.problems[0].severity, ProblemSeverity::Warning);
225 }
226
227 #[test]
228 fn a_javac_warning_is_still_a_warning() {
229 let mut decoder = TextProblemDecoder::new("/p".into());
230 let out = decoder.push(b"src/main/java/App.java:9: warning: deprecated API\n");
231 assert_eq!(out.problems[0].severity, ProblemSeverity::Warning);
232 }
233
234 #[test]
235 fn a_bare_timestamp_is_not_mistaken_for_a_location() {
236 let mut decoder = TextProblemDecoder::new("/p".into());
237 let out = decoder.push(b"12:30: build still running\n");
238 assert!(out.problems.is_empty());
239 }
240
241 #[test]
242 fn it_matches_a_python_traceback_frame() {
243 let mut decoder = TextProblemDecoder::new("/p".into());
244 let out = decoder.push(b" File \"app.py\", line 12, in handler\n");
245 assert_eq!(out.problems[0].line, 12);
246 }
247
248 #[test]
249 fn unrecognised_output_reaches_the_display_untouched() {
250 let mut decoder = TextProblemDecoder::new("/p".into());
251 let line = b"\x1b[32m vite v5 ready in 300ms\x1b[0m\n";
252 let out = decoder.push(line);
253 assert!(out.problems.is_empty());
254 assert_eq!(out.display, line);
255 }
256
257 #[test]
258 fn a_line_split_across_reads_is_matched_once_it_completes() {
259 let mut decoder = TextProblemDecoder::new("/p".into());
260 let first = decoder.push(b"src/app.js:12:");
261 assert!(first.problems.is_empty());
262 assert_eq!(first.display, b"src/app.js:12:");
263
264 let second = decoder.push(b"5: error: broken\n");
265 assert_eq!(second.problems.len(), 1);
266 assert_eq!(second.display, b"5: error: broken\n");
267 }
268
269 #[test]
270 fn absolute_paths_are_kept_and_traversal_is_refused() {
271 let mut decoder = TextProblemDecoder::new("/p".into());
272 let absolute = decoder.push(b"/outside/app.js:2:3: error: broken\n");
273 assert_eq!(absolute.problems[0].path, Path::new("/outside/app.js"));
274
275 let traversal = decoder.push(b"../secret.js:2:3: error: hidden\n");
276 assert!(traversal.problems.is_empty());
277 }
278}