Skip to content
430 linesCodeBlameRaw
1//! Problem matchers, as GitHub's runner has them. `::add-matcher::` reads a
2//! JSON file of patterns (`actions/setup-node`, `setup-go` and
3//! `setup-python` each add some), and from then on, for the rest of the
4//! job, an output line they match becomes an annotation in place of the
5//! line. A pattern can span lines, and the last one of several can `loop`
6//! to match many issues after one header.
7
8use std::path::{Path, PathBuf};
9
10use regex::Regex;
11use serde_json::{Map, Value};
12
13/// One line's pattern, and which of its groups hold what.
14#[derive(Debug)]
15struct Pattern {
16 regex: Regex,
17 file: Option<usize>,
18 line: Option<usize>,
19 column: Option<usize>,
20 severity: Option<usize>,
21 code: Option<usize>,
22 message: Option<usize>,
23 from_path: Option<usize>,
24 looping: bool,
25}
26
27/// What the lines matched so far say about an issue.
28#[derive(Clone, Debug, Default, PartialEq)]
29pub(crate) struct Found {
30 pub(crate) file: Option<String>,
31 pub(crate) line: Option<String>,
32 pub(crate) column: Option<String>,
33 pub(crate) severity: Option<String>,
34 pub(crate) code: Option<String>,
35 pub(crate) message: Option<String>,
36 pub(crate) from_path: Option<String>,
37}
38
39impl Found {
40 /// `running` with what `pattern` read from `caps` over it.
41 fn read(running: Option<&Found>, pattern: &Pattern, caps: &regex::Captures) -> Found {
42 let mut found = running.cloned().unwrap_or_default();
43 let group = |index: Option<usize>, into: &mut Option<String>| {
44 if let Some(index) = index {
45 *into = caps.get(index).map(|m| m.as_str().to_owned());
46 }
47 };
48 group(pattern.file, &mut found.file);
49 group(pattern.line, &mut found.line);
50 group(pattern.column, &mut found.column);
51 group(pattern.severity, &mut found.severity);
52 group(pattern.code, &mut found.code);
53 group(pattern.message, &mut found.message);
54 group(pattern.from_path, &mut found.from_path);
55 found
56 }
57}
58
59/// One matcher: its owner, the severity it gives by default, its patterns,
60/// and how far into them the output has gone.
61#[derive(Debug)]
62pub(crate) struct Matcher {
63 pub(crate) owner: String,
64 severity: Option<String>,
65 patterns: Vec<Pattern>,
66 /// For each pattern but the last, what it matched on the line before.
67 state: Vec<Option<Found>>,
68}
69
70impl Matcher {
71 fn reset(&mut self) {
72 self.state.iter_mut().for_each(|s| *s = None);
73 }
74
75 /// The issue `line` completes, if it does, as GitHub's runner reads one:
76 /// a single pattern matches by itself; several are worked through from
77 /// the last back, each needing the one before it to have matched the
78 /// line before.
79 pub(crate) fn matches(&mut self, line: &str) -> Option<Found> {
80 let last = self.patterns.len() - 1;
81 if last == 0 {
82 let caps = self.patterns[0].regex.captures(line)?;
83 return Some(self.with_default(Found::read(None, &self.patterns[0], &caps)));
84 }
85 for i in (0..=last).rev() {
86 let running = if i > 0 { self.state[i - 1].clone() } else { None };
87 if i > 0 && running.is_none() {
88 continue;
89 }
90 let pattern = &self.patterns[i];
91 match pattern.regex.captures(line) {
92 Some(caps) if i == last => {
93 let found = Found::read(running.as_ref(), pattern, &caps);
94 let looping = pattern.looping;
95 self.reset();
96 // A loop keeps what led up to it, for the next line.
97 if looping {
98 self.state[i - 1] = running;
99 }
100 return Some(self.with_default(found));
101 }
102 Some(caps) => self.state[i] = Some(Found::read(running.as_ref(), pattern, &caps)),
103 None if i == last => self.state[i - 1] = None,
104 None => self.state[i] = None,
105 }
106 }
107 None
108 }
109
110 fn with_default(&self, mut found: Found) -> Found {
111 if found.severity.as_deref().is_none_or(str::is_empty) {
112 found.severity = self.severity.clone();
113 }
114 found
115 }
116}
117
118/// Reads a problem matcher file: `{"problemMatcher": [{"owner",
119/// "severity"?, "pattern": [{"regexp", "file", "line", "column",
120/// "severity", "code", "message", "fromPath", "loop"}]}]}`.
121pub(crate) fn parse(text: &str) -> Result<Vec<Matcher>, String> {
122 let config: Value = serde_json::from_str(text).map_err(|e| format!("The problem matcher file is not JSON: {e}"))?;
123 let Some(list) = config.get("problemMatcher").and_then(Value::as_array) else {
124 return Err("The problem matcher file has no `problemMatcher` list.".into());
125 };
126 list.iter().map(parse_matcher).collect()
127}
128
129fn parse_matcher(config: &Value) -> Result<Matcher, String> {
130 let owner = config.get("owner").and_then(Value::as_str).filter(|o| !o.trim().is_empty()).ok_or("A problem matcher needs an `owner`.")?;
131 let severity = config.get("severity").and_then(Value::as_str).map(str::to_owned);
132 let patterns: Vec<&Value> = match config.get("pattern") {
133 Some(Value::Array(items)) => items.iter().collect(),
134 Some(item @ Value::Object(_)) => vec![item],
135 _ => Vec::new(),
136 };
137 if patterns.is_empty() {
138 return Err(format!("The problem matcher `{owner}` has no patterns."));
139 }
140 let count = patterns.len();
141 let mut read = Vec::new();
142 for (index, pattern) in patterns.into_iter().enumerate() {
143 let regexp = pattern.get("regexp").and_then(Value::as_str).filter(|r| !r.is_empty()).ok_or(format!("A pattern of `{owner}` needs a `regexp`."))?;
144 let regex = Regex::new(regexp).map_err(|e| format!("A pattern of `{owner}` does not read: {e}"))?;
145 let group = |key: &str| -> Result<Option<usize>, String> {
146 match pattern.get(key) {
147 None | Some(Value::Null) => Ok(None),
148 Some(value) => value.as_u64().map(|n| Some(n as usize)).ok_or(format!("`{key}` in a pattern of `{owner}` must be a group's number.")),
149 }
150 };
151 let looping = pattern.get("loop").and_then(Value::as_bool).unwrap_or(false);
152 let read_pattern = Pattern {
153 regex,
154 file: group("file")?,
155 line: group("line")?,
156 column: group("column")?,
157 severity: group("severity")?,
158 code: group("code")?,
159 message: group("message")?,
160 from_path: group("fromPath")?,
161 looping,
162 };
163 if looping && (count == 1 || index != count - 1) {
164 return Err(format!("Only the last of several patterns may `loop` (`{owner}`)."));
165 }
166 if looping && read_pattern.message.is_none() {
167 return Err(format!("The looping pattern of `{owner}` must set `message`."));
168 }
169 read.push(read_pattern);
170 }
171 if !read.iter().any(|p| p.message.is_some()) {
172 return Err(format!("One of the patterns of `{owner}` must set `message`."));
173 }
174 Ok(Matcher { owner: owner.to_owned(), severity, state: vec![None; read.len() - 1], patterns: read })
175}
176
177/// An issue a matcher found: the annotation's level, its message, and its
178/// `file`, `line`, `col` and `code`, as an `::error::` command's properties.
179#[derive(Debug, PartialEq)]
180pub(crate) struct Issue {
181 pub(crate) level: &'static str,
182 pub(crate) message: String,
183 pub(crate) properties: Map<String, Value>,
184}
185
186/// The job's matchers, in the order they were added.
187#[derive(Default)]
188pub(crate) struct Matchers {
189 list: Vec<Matcher>,
190 /// Where relative files are read from: `github.workspace`.
191 pub(crate) workspace: PathBuf,
192}
193
194impl Matchers {
195 /// Adds matchers; one with an owner already here takes its place.
196 pub(crate) fn add(&mut self, new: Vec<Matcher>) {
197 for matcher in new {
198 match self.list.iter_mut().find(|m| m.owner.eq_ignore_ascii_case(&matcher.owner)) {
199 Some(old) => *old = matcher,
200 None => self.list.push(matcher),
201 }
202 }
203 }
204
205 pub(crate) fn remove(&mut self, owner: &str) {
206 self.list.retain(|m| !m.owner.eq_ignore_ascii_case(owner));
207 }
208
209 /// The issue `line` makes, if a matcher reads one in it. The first
210 /// matcher to match has it, and the others start over.
211 pub(crate) fn issue(&mut self, line: &str) -> Option<Issue> {
212 if self.list.is_empty() {
213 return None;
214 }
215 let line = strip_colors(line);
216 for index in 0..self.list.len() {
217 let Some(found) = self.list[index].matches(&line) else { continue };
218 for (other, matcher) in self.list.iter_mut().enumerate() {
219 if other != index {
220 matcher.reset();
221 }
222 }
223 if let Some(issue) = to_issue(&found, &self.workspace) {
224 return Some(issue);
225 }
226 }
227 None
228 }
229}
230
231/// `\x1b[…m` colour codes, which a matcher never sees.
232fn strip_colors(line: &str) -> String {
233 let mut out = String::with_capacity(line.len());
234 let mut rest = line;
235 while let Some(start) = rest.find("\x1b[") {
236 out.push_str(&rest[..start]);
237 let after = &rest[start + 2..];
238 let end = after.find(|c: char| !(c.is_ascii_digit() || c == ';'));
239 match end {
240 Some(end) if after[end..].starts_with('m') => rest = &after[end + 1..],
241 _ => {
242 out.push_str("\x1b[");
243 rest = after;
244 }
245 }
246 }
247 out.push_str(rest);
248 out
249}
250
251/// What GitHub's runner makes of a match: nothing without a message or
252/// with a severity other than error, warning or notice; a line and column
253/// only when they are numbers; a file only when it is there, inside a
254/// repository in the workspace, and then relative to that repository.
255fn to_issue(found: &Found, workspace: &Path) -> Option<Issue> {
256 let message = found.message.as_deref().filter(|m| !m.trim().is_empty())?;
257 let level = match found.severity.as_deref().map(str::to_ascii_lowercase).as_deref() {
258 None | Some("") | Some("error") => "error",
259 Some("warning") => "warning",
260 Some("notice") => "notice",
261 Some(_) => return None,
262 };
263 let mut properties = Map::new();
264 for (key, value) in [("line", &found.line), ("col", &found.column)] {
265 if let Some(number) = value.as_deref().and_then(|v| v.trim().parse::<u32>().ok()) {
266 properties.insert(key.into(), Value::String(number.to_string()));
267 }
268 }
269 if let Some(code) = found.code.as_deref().map(str::trim).filter(|c| !c.is_empty()) {
270 properties.insert("code".into(), Value::String(code.to_owned()));
271 }
272 if let Some(file) = found.file.as_deref().map(str::trim).filter(|f| !f.is_empty())
273 && let Some(file) = repository_file(file, found.from_path.as_deref(), workspace)
274 {
275 properties.insert("file".into(), Value::String(file));
276 }
277 Some(Issue { level, message: message.to_owned(), properties })
278}
279
280/// `file` as a path inside its repository, if it is in one in the workspace.
281fn repository_file(file: &str, from_path: Option<&str>, workspace: &Path) -> Option<String> {
282 let mut path = PathBuf::from(file.replace('\\', "/"));
283 if !path.is_absolute()
284 && let Some(from) = from_path.map(Path::new).and_then(Path::parent).filter(|p| !p.as_os_str().is_empty())
285 {
286 path = from.join(path);
287 }
288 if !path.is_absolute() {
289 path = workspace.join(path);
290 }
291 if !path.is_file() {
292 return None;
293 }
294 let repository = path.ancestors().skip(1).take_while(|dir| dir.starts_with(workspace)).find(|dir| dir.join(".git").exists())?;
295 let relative = path.strip_prefix(repository).ok()?;
296 let parts: Vec<String> = relative.iter().map(|part| part.to_string_lossy().into_owned()).collect();
297 Some(parts.join("/"))
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 /// actions/setup-node's `tsc` matcher, as it ships.
305 const TSC: &str = r#"{
306 "problemMatcher": [{
307 "owner": "tsc",
308 "pattern": [{
309 "regexp": "^([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$",
310 "file": 1, "line": 2, "column": 3, "severity": 4, "code": 5, "message": 6
311 }]
312 }]
313 }"#;
314
315 /// actions/setup-node's `eslint-stylish`: a file, then its issues.
316 const STYLISH: &str = r#"{
317 "problemMatcher": [{
318 "owner": "eslint-stylish",
319 "pattern": [
320 { "regexp": "^([^\\s].*)$", "file": 1 },
321 { "regexp": "^\\s+(\\d+):(\\d+)\\s+(error|warning|info)\\s+(.*)\\s\\s+(.*)$", "line": 1, "column": 2, "severity": 3, "message": 4, "code": 5, "loop": true }
322 ]
323 }]
324 }"#;
325
326 #[test]
327 fn a_single_pattern_reads_its_groups() {
328 let mut matchers = parse(TSC).unwrap();
329 assert_eq!(matchers[0].owner, "tsc");
330 let found = matchers[0].matches("src/app.ts(12,5): error TS2322: Type 'string' is not assignable.").unwrap();
331 assert_eq!(found.file.as_deref(), Some("src/app.ts"));
332 assert_eq!(found.line.as_deref(), Some("12"));
333 assert_eq!(found.column.as_deref(), Some("5"));
334 assert_eq!(found.severity.as_deref(), Some("error"));
335 assert_eq!(found.code.as_deref(), Some("2322"));
336 assert_eq!(found.message.as_deref(), Some("Type 'string' is not assignable."));
337 assert!(matchers[0].matches("Compiled successfully.").is_none());
338 }
339
340 #[test]
341 fn several_patterns_span_lines_and_a_loop_goes_on() {
342 let mut matcher = parse(STYLISH).unwrap().remove(0);
343 assert!(matcher.matches("/w/src/a.js").is_none(), "the header alone is no issue");
344 let first = matcher.matches(" 1:10 error 'x' is defined but never used no-unused-vars").unwrap();
345 assert_eq!(first.file.as_deref(), Some("/w/src/a.js"));
346 assert_eq!((first.line.as_deref(), first.column.as_deref()), (Some("1"), Some("10")));
347 assert_eq!(first.code.as_deref(), Some("no-unused-vars"));
348 // The loop keeps the file for the next issue.
349 let second = matcher.matches(" 3:1 warning Unexpected console statement no-console").unwrap();
350 assert_eq!(second.file.as_deref(), Some("/w/src/a.js"));
351 assert_eq!(second.severity.as_deref(), Some("warning"));
352 // A line that is neither ends the run.
353 assert!(matcher.matches("").is_none());
354 assert!(matcher.matches(" 4:1 error Late semi").is_none(), "no file is held after the run ended");
355 }
356
357 #[test]
358 fn without_a_loop_each_issue_needs_its_header() {
359 let config = r#"{"problemMatcher": [{"owner": "two", "severity": "warning", "pattern": [
360 {"regexp": "^FILE (.*)$", "file": 1},
361 {"regexp": "^MSG (.*)$", "message": 1}
362 ]}]}"#;
363 let mut matcher = parse(config).unwrap().remove(0);
364 assert!(matcher.matches("MSG orphan").is_none());
365 assert!(matcher.matches("FILE a.txt").is_none());
366 let found = matcher.matches("MSG broke").unwrap();
367 assert_eq!((found.file.as_deref(), found.message.as_deref()), (Some("a.txt"), Some("broke")));
368 assert_eq!(found.severity.as_deref(), Some("warning"), "the matcher's own severity");
369 assert!(matcher.matches("MSG again").is_none());
370 // A line between header and message breaks the pair.
371 matcher.matches("FILE b.txt");
372 matcher.matches("something else");
373 assert!(matcher.matches("MSG late").is_none());
374 }
375
376 #[test]
377 fn bad_matchers_are_refused() {
378 assert!(parse("not json").is_err());
379 assert!(parse(r#"{"problemMatcher": [{"pattern": [{"regexp": "x", "message": 0}]}]}"#).unwrap_err().contains("owner"));
380 assert!(parse(r#"{"problemMatcher": [{"owner": "a", "pattern": [{"regexp": "x"}]}]}"#).unwrap_err().contains("message"));
381 assert!(parse(r#"{"problemMatcher": [{"owner": "a", "pattern": [{"regexp": "x", "message": 0, "loop": true}]}]}"#).unwrap_err().contains("loop"));
382 assert!(parse(r#"{"problemMatcher": [{"owner": "a", "pattern": [{"regexp": "(", "message": 1}]}]}"#).is_err());
383 }
384
385 #[test]
386 fn matchers_are_replaced_by_owner_and_removed() {
387 let mut matchers = Matchers::default();
388 matchers.add(parse(TSC).unwrap());
389 matchers.add(parse(TSC).unwrap());
390 assert_eq!(matchers.list.len(), 1);
391 matchers.add(parse(STYLISH).unwrap());
392 assert_eq!(matchers.list.len(), 2);
393 matchers.remove("TSC");
394 assert_eq!(matchers.list[0].owner, "eslint-stylish");
395 }
396
397 #[test]
398 fn issues_carry_numbers_and_files_inside_the_repository() {
399 let workspace = std::env::temp_dir().join(format!("g1t-matchers-{}", std::process::id()));
400 std::fs::create_dir_all(workspace.join(".git")).unwrap();
401 std::fs::create_dir_all(workspace.join("src")).unwrap();
402 std::fs::write(workspace.join("src/app.ts"), "").unwrap();
403 let mut matchers = Matchers { workspace: workspace.clone(), ..Matchers::default() };
404 matchers.add(parse(TSC).unwrap());
405 let issue = matchers.issue("\x1b[31msrc/app.ts(12,5): error TS2322: Bad.\x1b[0m").unwrap();
406 assert_eq!(issue.level, "error");
407 assert_eq!(issue.message, "Bad.");
408 assert_eq!(issue.properties["file"], "src/app.ts");
409 assert_eq!(issue.properties["line"], "12");
410 assert_eq!(issue.properties["col"], "5");
411 assert_eq!(issue.properties["code"], "2322");
412 // A file that is not there is dropped; the issue stays.
413 let issue = matchers.issue("gone.ts(1,1): warning TS1: Missing.").unwrap();
414 assert_eq!(issue.level, "warning");
415 assert!(!issue.properties.contains_key("file"));
416 // An absolute path inside the workspace is made relative.
417 let absolute = format!("{}(2,3): error TS9: Abs.", workspace.join("src/app.ts").display());
418 assert_eq!(matchers.issue(&absolute).unwrap().properties["file"], "src/app.ts");
419 // `info` is no annotation GitHub has.
420 assert!(matchers.issue("src/app.ts(1,1): info TS1: Hint.").is_none());
421 let _ = std::fs::remove_dir_all(&workspace);
422 }
423
424 #[test]
425 fn colour_codes_are_stripped() {
426 assert_eq!(strip_colors("\x1b[1;31merror\x1b[0m: x"), "error: x");
427 assert_eq!(strip_colors("plain"), "plain");
428 assert_eq!(strip_colors("\x1b[Kodd"), "\x1b[Kodd");
429 }
430}