use std::collections::HashSet;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Diagnostic {
pub path: PathBuf,
pub line: u32,
pub col: Option<u32>,
}
pub fn parse(output: &str) -> Vec<Diagnostic> {
let mut out = Vec::new();
let mut seen: HashSet<(PathBuf, u32, Option<u32>)> = HashSet::new();
for line in output.lines() {
if let Some(d) = extract_line(line) {
let key = (d.path.clone(), d.line, d.col);
if seen.insert(key) {
out.push(d);
}
}
}
out
}
pub fn extract_line(line: &str) -> Option<Diagnostic> {
if let Some(idx) = line.find("--> ") {
if let Some(d) = parse_path_line_col(&line[idx + 4..]) {
return Some(d);
}
}
if let Some(idx) = line.find("::: ") {
if let Some(d) = parse_path_line_col(&line[idx + 4..]) {
return Some(d);
}
}
if let Some(idx) = line.find("panicked at ") {
let rest = &line[idx + "panicked at ".len()..];
if let Some(comma) = rest.find(", ") {
if let Some(d) = parse_path_line_col(&rest[comma + 2..]) {
return Some(d);
}
}
if let Some(d) = parse_path_line_col(rest) {
return Some(d);
}
}
parse_path_line_col(line.trim_start())
}
fn parse_path_line_col(s: &str) -> Option<Diagnostic> {
let bytes = s.as_bytes();
let mut i = 0;
let path_end = loop {
if i >= bytes.len() {
return None;
}
let c = bytes[i];
if c.is_ascii_whitespace() {
return None;
}
if c == b':' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
break i;
}
i += 1;
};
if path_end == 0 {
return None;
}
let path_str = &s[..path_end];
if !path_str.contains('/') && !path_str.contains('.') && !path_str.contains('\\') {
return None;
}
let line_start = path_end + 1;
let mut line_end = line_start;
while line_end < bytes.len() && bytes[line_end].is_ascii_digit() {
line_end += 1;
}
let line: u32 = s[line_start..line_end].parse().ok()?;
let col = if line_end < bytes.len()
&& bytes[line_end] == b':'
&& bytes
.get(line_end + 1)
.map(|b| b.is_ascii_digit())
.unwrap_or(false)
{
let col_start = line_end + 1;
let mut col_end = col_start;
while col_end < bytes.len() && bytes[col_end].is_ascii_digit() {
col_end += 1;
}
s[col_start..col_end].parse().ok()
} else {
None
};
Some(Diagnostic {
path: PathBuf::from(path_str),
line,
col,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn diag(path: &str, line: u32, col: Option<u32>) -> Diagnostic {
Diagnostic {
path: PathBuf::from(path),
line,
col,
}
}
#[test]
fn parses_rustc_primary_span() {
let input = "error[E0277]: trait bound\n --> src/foo.rs:42:5\n |\n42 | ...";
let diags = parse(input);
assert_eq!(diags, vec![diag("src/foo.rs", 42, Some(5))]);
}
#[test]
fn parses_clippy_warning() {
let input = "warning: unused variable: `x`\n --> src/bar.rs:10:9\n";
assert_eq!(parse(input), vec![diag("src/bar.rs", 10, Some(9))]);
}
#[test]
fn parses_rustc_context_span() {
let input = " --> a.rs:1:1\n ::: b.rs:2:2\n";
assert_eq!(
parse(input),
vec![diag("a.rs", 1, Some(1)), diag("b.rs", 2, Some(2))]
);
}
#[test]
fn parses_panic_modern() {
let input = "thread 'main' panicked at src/foo.rs:42:5:\nassertion failed: false";
assert_eq!(parse(input), vec![diag("src/foo.rs", 42, Some(5))]);
}
#[test]
fn parses_panic_legacy() {
let input = "thread 'main' panicked at 'assertion failed', src/foo.rs:42:5";
assert_eq!(parse(input), vec![diag("src/foo.rs", 42, Some(5))]);
}
#[test]
fn parses_generic_filename_line_col() {
let input = "src/foo.ts:42:5: error: blah\nsrc/bar.py:10: error: blah";
assert_eq!(
parse(input),
vec![
diag("src/foo.ts", 42, Some(5)),
diag("src/bar.py", 10, None),
]
);
}
#[test]
fn rejects_lines_without_path_separator() {
let input = "Caused by: foo:42 stuff\n";
assert!(parse(input).is_empty());
}
#[test]
fn dedups_same_location() {
let input = " --> src/foo.rs:42:5\n --> src/foo.rs:42:5\n";
assert_eq!(parse(input).len(), 1);
}
#[test]
fn keeps_distinct_locations_in_same_file() {
let input = " --> src/foo.rs:42:5\n --> src/foo.rs:50:1\n";
assert_eq!(parse(input).len(), 2);
}
#[test]
fn handles_absolute_paths() {
let input = " --> /home/me/foo.rs:1:1\n";
assert_eq!(parse(input), vec![diag("/home/me/foo.rs", 1, Some(1))]);
}
#[test]
fn empty_input_returns_empty() {
assert!(parse("").is_empty());
}
#[test]
fn no_colon_digit_returns_none() {
assert!(parse("just text with no diags\n").is_empty());
}
}