| 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 | |
| 8 | use std::path::{Path, PathBuf}; |
| 9 | |
| 10 | use regex::Regex; |
| 11 | use serde_json::{Map, Value}; |
| 12 | |
| 13 | /// One line's pattern, and which of its groups hold what. |
| 14 | #[derive(Debug)] |
| 15 | struct 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)] |
| 29 | pub(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 | |
| 39 | impl Found { |
| 40 | /// `running` with what `pattern` read from `caps` over it. |
| 41 | fn read(running: Option<&Found>, pattern: &Pattern, caps: ®ex::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)] |
| 62 | pub(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 | |
| 70 | impl 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"}]}]}`. |
| 121 | pub(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 | |
| 129 | fn 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)] |
| 180 | pub(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)] |
| 188 | pub(crate) struct Matchers { |
| 189 | list: Vec<Matcher>, |
| 190 | /// Where relative files are read from: `github.workspace`. |
| 191 | pub(crate) workspace: PathBuf, |
| 192 | } |
| 193 | |
| 194 | impl 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. |
| 232 | fn 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. |
| 255 | fn 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. |
| 281 | fn 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)] |
| 301 | mod 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 | } |