use regex::Regex;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ErrorRef {
pub path: String,
pub line: Option<u32>,
pub symbol: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ParsedErrors {
pub refs: Vec<ErrorRef>,
pub is_build_error: bool,
}
pub fn is_build_test_command(command: &str) -> bool {
let cmd = command.trim();
let patterns = [
"cargo build",
"cargo test",
"cargo check",
"cargo clippy",
"cargo run",
"go build",
"go test",
"go vet",
"go run",
"pytest",
"python -m pytest",
"python3 -m pytest",
"python -m unittest",
"python3 -m unittest",
"npm test",
"npm run test",
"npm run build",
"npx tsc",
"tsc ",
"yarn test",
"yarn build",
"pnpm test",
"pnpm build",
"node ",
"deno test",
"bun test",
"make",
"cmake",
"gcc ",
"g++ ",
"clang ",
"clang++ ",
"mvn ",
"gradle ",
"dotnet build",
"dotnet test",
"golangci-lint",
];
patterns
.iter()
.any(|p| cmd.starts_with(p) || cmd.contains(&format!(" && {}", p)))
}
pub fn parse_error_output(error: &str, command: &str) -> ParsedErrors {
let is_build = is_build_test_command(command);
let mut refs_map: BTreeMap<String, ErrorRef> = BTreeMap::new();
let all_refs = if command.contains("cargo") || command.contains("rustc") {
parse_rust_errors(error)
} else if command.contains("go ") || command.contains("golangci") {
parse_go_errors(error)
} else if command.contains("pytest") || command.contains("python") {
parse_python_errors(error)
} else if command.contains("tsc")
|| command.contains("node")
|| command.contains("deno")
|| command.contains("bun")
{
parse_typescript_errors(error)
} else {
let mut combined = parse_rust_errors(error);
combined.extend(parse_go_errors(error));
combined.extend(parse_python_errors(error));
combined.extend(parse_typescript_errors(error));
combined.extend(parse_generic_file_refs(error));
combined
};
let mut final_refs = all_refs;
if final_refs.is_empty() {
final_refs = parse_generic_file_refs(error);
}
for r in final_refs {
refs_map.entry(r.path.clone()).or_insert(r);
}
ParsedErrors {
refs: refs_map.into_values().collect(),
is_build_error: is_build,
}
}
fn parse_rust_errors(error: &str) -> Vec<ErrorRef> {
let mut refs = Vec::new();
let re = Regex::new(r"-->\s+([^\s:]+):(\d+):\d+").unwrap();
for cap in re.captures_iter(error) {
refs.push(ErrorRef {
path: cap[1].to_string(),
line: cap[2].parse().ok(),
symbol: None,
});
}
let sym_re =
Regex::new(r"cannot find (?:value|type|function|trait|module|struct|macro) `(\w+)`")
.unwrap();
for cap in sym_re.captures_iter(error) {
if let Some(last) = refs.last_mut() {
if last.symbol.is_none() {
last.symbol = Some(cap[1].to_string());
}
}
}
let test_re = Regex::new(r"test (\S+) \.\.\. FAILED").unwrap();
for cap in test_re.captures_iter(error) {
let test_path = &cap[1];
if let Some(module) = test_path.rsplit("::").nth(1) {
refs.push(ErrorRef {
path: module.replace("::", "/"),
line: None,
symbol: test_path.rsplit("::").next().map(|s| s.to_string()),
});
}
}
refs
}
fn parse_go_errors(error: &str) -> Vec<ErrorRef> {
let mut refs = Vec::new();
let re = Regex::new(r"([./][\w/.-]+\.go):(\d+)(?::\d+)?:?\s*(.*)").unwrap();
for cap in re.captures_iter(error) {
let msg = cap.get(3).map_or("", |m| m.as_str());
let symbol = if msg.contains("undefined:") {
msg.split("undefined:").nth(1).map(|s| s.trim().to_string())
} else {
None
};
refs.push(ErrorRef {
path: cap[1].to_string(),
line: cap[2].parse().ok(),
symbol,
});
}
let test_re = Regex::new(r"--- FAIL: (\w+)").unwrap();
for cap in test_re.captures_iter(error) {
refs.push(ErrorRef {
path: String::new(), line: None,
symbol: Some(cap[1].to_string()),
});
}
refs.retain(|r| !r.path.is_empty() || r.symbol.is_some());
refs
}
fn parse_python_errors(error: &str) -> Vec<ErrorRef> {
let mut refs = Vec::new();
let pytest_re = Regex::new(r"FAILED\s+([^\s:]+\.py)::(\S+)").unwrap();
for cap in pytest_re.captures_iter(error) {
refs.push(ErrorRef {
path: cap[1].to_string(),
line: None,
symbol: Some(cap[2].to_string()),
});
}
let tb_re = Regex::new(r#"File "([^"]+\.py)", line (\d+)(?:, in (\w+))?"#).unwrap();
for cap in tb_re.captures_iter(error) {
let path = &cap[1];
if path.contains("site-packages") || path.contains("/lib/python") {
continue;
}
refs.push(ErrorRef {
path: path.to_string(),
line: cap[2].parse().ok(),
symbol: cap.get(3).map(|m| m.as_str().to_string()),
});
}
refs
}
fn parse_typescript_errors(error: &str) -> Vec<ErrorRef> {
let mut refs = Vec::new();
let tsc_re = Regex::new(r"([^\s(]+\.(?:ts|tsx|js|jsx))\((\d+),\d+\):\s*error").unwrap();
for cap in tsc_re.captures_iter(error) {
refs.push(ErrorRef {
path: cap[1].to_string(),
line: cap[2].parse().ok(),
symbol: None,
});
}
let stack_re =
Regex::new(r"at\s+\S+\s+\(?([^\s():]+\.(?:ts|tsx|js|jsx)):(\d+):\d+\)?").unwrap();
for cap in stack_re.captures_iter(error) {
let path = &cap[1];
if path.contains("node_modules") || path.contains("internal/") {
continue;
}
refs.push(ErrorRef {
path: path.to_string(),
line: cap[2].parse().ok(),
symbol: None,
});
}
refs
}
fn parse_generic_file_refs(error: &str) -> Vec<ErrorRef> {
let mut refs = Vec::new();
let re = Regex::new(
r"([./]?[\w/.-]+\.(?:rs|go|py|ts|tsx|js|jsx|java|kt|c|cpp|h|hpp|rb|ex|exs|zig|swift|cs|lua|sh)):(\d+)(?::\d+)?"
).unwrap();
for cap in re.captures_iter(error) {
let path = &cap[1];
if path.contains("://") || path.contains("/target/") || path.contains("__pycache__") {
continue;
}
refs.push(ErrorRef {
path: path.to_string(),
line: cap[2].parse().ok(),
symbol: None,
});
}
refs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rust_error_parsing() {
let error = r#"error[E0425]: cannot find value `foo` in this scope
--> src/main.rs:42:5
|
42 | foo.bar();
| ^^^ not found in this scope
error[E0308]: mismatched types
--> src/lib.rs:10:12
|
10 | return "hello";
| ^^^^^^^ expected i32, found &str"#;
let parsed = parse_error_output(error, "cargo build");
assert!(parsed.is_build_error);
assert_eq!(parsed.refs.len(), 2);
assert_eq!(parsed.refs[0].path, "src/lib.rs");
assert_eq!(parsed.refs[1].path, "src/main.rs");
}
#[test]
fn test_go_error_parsing() {
let error = r#"./cmd/main.go:42:5: undefined: processRequest
./internal/handler.go:15:2: cannot use x (type string) as type int"#;
let parsed = parse_error_output(error, "go build ./...");
assert!(parsed.is_build_error);
assert_eq!(parsed.refs.len(), 2);
assert_eq!(parsed.refs[0].path, "./cmd/main.go");
assert_eq!(parsed.refs[0].symbol.as_deref(), Some("processRequest"));
}
#[test]
fn test_python_error_parsing() {
let error = r#"FAILED tests/test_api.py::TestAuth::test_login_invalid
File "src/auth.py", line 42, in validate
raise ValueError("invalid token")
File "src/handlers.py", line 15, in handle_login
auth.validate(token)"#;
let parsed = parse_error_output(error, "pytest tests/");
assert!(parsed.is_build_error);
assert!(parsed.refs.len() >= 3);
}
#[test]
fn test_typescript_error_parsing() {
let error = r#"src/index.ts(42,5): error TS2304: Cannot find name 'foo'.
src/utils.ts(10,3): error TS2345: Argument of type 'string' is not assignable."#;
let parsed = parse_error_output(error, "npx tsc --noEmit");
assert!(parsed.is_build_error);
assert_eq!(parsed.refs.len(), 2);
}
#[test]
fn test_is_build_command() {
assert!(is_build_test_command("cargo build"));
assert!(is_build_test_command("cargo test -- --nocapture"));
assert!(is_build_test_command("go test ./..."));
assert!(is_build_test_command("pytest tests/"));
assert!(is_build_test_command("make all"));
assert!(!is_build_test_command("ls -la"));
assert!(!is_build_test_command("git status"));
}
#[test]
fn test_deduplication() {
let error = r#"error[E0425]: ...
--> src/main.rs:42:5
error[E0308]: ...
--> src/main.rs:50:10"#;
let parsed = parse_error_output(error, "cargo build");
assert_eq!(parsed.refs.len(), 1);
assert_eq!(parsed.refs[0].path, "src/main.rs");
assert_eq!(parsed.refs[0].line, Some(42));
}
#[test]
fn test_generic_fallback() {
let error = "error in src/config.rs:25:3: expected `;`";
let parsed = parse_error_output(error, "some-custom-build-tool");
assert_eq!(parsed.refs.len(), 1);
assert_eq!(parsed.refs[0].path, "src/config.rs");
}
}