use std::sync::LazyLock;
use regex::Regex;
use serde_json::Value;
use crate::analysis::findings::{Finding, Severity};
use crate::languages::runner::ToolOutputError;
use crate::languages::spec::ToolSpec;
static POSITION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:vet:\s*)?(?P<file>[^\s:][^:]*):(?P<line>\d+):(?P<col>\d+):\s*(?P<message>.+)$")
.expect("POSITION regex compiles")
});
static TSC: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^(?P<file>.+?)\((?P<line>\d+),(?P<col>\d+)\):\s*(?:error|warning)\s+(?P<code>TS\d+):\s*(?P<message>.+)$",
)
.expect("TSC regex compiles")
});
pub fn parse_output(
spec: &ToolSpec,
output: &str,
root_name: &str,
) -> Result<Vec<Finding>, ToolOutputError> {
match spec.output_format {
"lines" => Ok(parse_lines(spec, output)),
"json" => parse_json(spec, output, root_name),
"position" => Ok(parse_positions(spec, output)),
"tsc" => Ok(parse_tsc(spec, output)),
"cargo" => parse_cargo(spec, output),
other => Err(ToolOutputError(format!(
"{}: no parser for output format {other:?}",
spec.name,
))),
}
}
fn parse_lines(spec: &ToolSpec, output: &str) -> Vec<Finding> {
output
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let path = line.trim();
let suggest = format!(
"Run `{base} -w {path}`",
base = spec.command[..spec.command.len() - 1].join(" "),
);
Finding::deterministic(
spec.name.to_owned(),
Severity::Error,
path.to_owned(),
1,
None,
format!("{}: file is not formatted", spec.name),
Some(suggest),
)
})
.collect()
}
fn parse_positions(spec: &ToolSpec, output: &str) -> Vec<Finding> {
let mut findings = Vec::new();
for line in output.lines() {
let Some(caps) = POSITION.captures(line.trim()) else {
continue;
};
let file = caps.name("file").map(|m| m.as_str()).unwrap_or("");
let line_num = caps
.name("line")
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(1);
let col = caps
.name("col")
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(1);
let message = caps
.name("message")
.map(|m| m.as_str().trim())
.unwrap_or("");
findings.push(Finding::deterministic(
spec.name.to_owned(),
Severity::Error,
file.strip_prefix("./").unwrap_or(file).to_owned(),
line_num,
Some(col),
message.to_owned(),
None,
));
}
findings
}
fn parse_tsc(spec: &ToolSpec, output: &str) -> Vec<Finding> {
let mut findings = Vec::new();
for line in output.lines() {
let Some(caps) = TSC.captures(line.trim()) else {
continue;
};
let file = caps.name("file").map(|m| m.as_str()).unwrap_or("");
let line_num = caps
.name("line")
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(1);
let col = caps
.name("col")
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(1);
let code = caps.name("code").map(|m| m.as_str()).unwrap_or(spec.name);
let message = caps
.name("message")
.map(|m| m.as_str().trim())
.unwrap_or("");
findings.push(Finding::deterministic(
code.to_owned(),
Severity::Error,
file.to_owned(),
line_num,
Some(col),
message.to_owned(),
None,
));
}
findings
}
fn parse_cargo(spec: &ToolSpec, output: &str) -> Result<Vec<Finding>, ToolOutputError> {
let mut findings = Vec::new();
for line in output.lines() {
if line.trim().is_empty() {
continue;
}
let event: Value = serde_json::from_str(line).map_err(|err| {
ToolOutputError(format!("{} emitted a non-JSON line: {err}", spec.name))
})?;
if event.get("reason").and_then(Value::as_str) != Some("compiler-message") {
continue;
}
let message = event.get("message").cloned().unwrap_or(Value::Null);
let primary = message
.get("spans")
.and_then(Value::as_array)
.and_then(|spans| {
spans
.iter()
.find(|s| s.get("is_primary") == Some(&Value::Bool(true)))
})
.cloned()
.unwrap_or(Value::Null);
let kind = message
.get("code")
.and_then(|c| c.get("code"))
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| spec.name.to_owned());
let file_path = primary
.get("file_name")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let line = primary
.get("line_start")
.and_then(Value::as_u64)
.map(|n| n as u32)
.unwrap_or(1);
let column = primary
.get("column_start")
.and_then(Value::as_u64)
.map(|n| n as u32);
let message_text = message
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
findings.push(Finding::deterministic(
kind,
Severity::Error,
file_path,
line,
column,
message_text,
None,
));
}
Ok(findings)
}
fn parse_json(
spec: &ToolSpec,
output: &str,
root_name: &str,
) -> Result<Vec<Finding>, ToolOutputError> {
let trimmed = output.trim();
let payload: Value = if trimmed.is_empty() {
Value::Array(Vec::new())
} else {
serde_json::from_str(trimmed).map_err(|err| {
ToolOutputError(format!("{} produced unparseable JSON: {err}", spec.name))
})?
};
let entries = payload.as_array().ok_or_else(|| {
ToolOutputError(format!(
"{}: expected a JSON array, got {}",
spec.name,
json_kind_name(&payload)
))
})?;
let mut findings = Vec::new();
for entry in entries {
let obj = entry.as_object().ok_or_else(|| {
ToolOutputError(format!("{}: expected objects in the array", spec.name))
})?;
if let Some(location) = obj.get("location") {
let location = location.as_object();
let row = location
.and_then(|l| l.get("row"))
.and_then(Value::as_u64)
.map(|n| n as u32)
.unwrap_or(1);
let column = location
.and_then(|l| l.get("column"))
.and_then(Value::as_u64)
.map(|n| n as u32);
let file_path = obj
.get("filename")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| root_name.to_owned());
let message = obj
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let kind = obj
.get("code")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| spec.name.to_owned());
let suggestion = obj
.get("fix")
.and_then(|f| f.get("message"))
.and_then(Value::as_str)
.map(str::to_owned);
findings.push(Finding::deterministic(
kind,
Severity::Error,
file_path,
row,
column,
message,
suggestion,
));
continue;
}
let file_path = obj
.get("filePath")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| root_name.to_owned());
let messages = obj
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
for message in messages {
let obj = message.as_object();
let line = obj
.and_then(|m| m.get("line"))
.and_then(Value::as_u64)
.map(|n| n as u32)
.unwrap_or(1);
let column = obj
.and_then(|m| m.get("column"))
.and_then(Value::as_u64)
.map(|n| n as u32);
let message_text = obj
.and_then(|m| m.get("message"))
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let kind = obj
.and_then(|m| m.get("ruleId"))
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| spec.name.to_owned());
findings.push(Finding::deterministic(
kind,
Severity::Error,
file_path.clone(),
line,
column,
message_text,
None,
));
}
}
Ok(findings)
}
fn json_kind_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}