Skip to main content

termesh_tasks/
text_problems.rs

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    // The leading token, not the whole message: `error TS2531: ... possibly a warning
140    // about ...` is an error, and searching the body for the word would demote it.
141    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        // Severity comes from the leading token. Searching the whole message would
174        // demote any diagnostic whose text happens to discuss a warning.
175        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        // javac emits a line but no column, so neither existing shape matches and Java
191        // build failures currently produce no problems at all.
192        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        // Ordering matters: try the column shape first, or `app.js:12:5: broken`
206        // becomes line 12 with the message "5: broken".
207        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}