use aver::types::checker::TypeError;
use colored::Colorize;
use std::fmt::Write;
pub(super) enum Severity {
Error,
Warning,
Fail,
}
pub(super) struct Span {
pub file: String,
pub line: usize,
pub col: usize,
}
pub(super) struct SourceLine {
pub line_num: usize,
pub text: String,
}
pub(super) struct Underline {
pub col: usize,
pub len: usize,
pub label: String,
}
pub(super) struct AnnotatedRegion {
pub source_lines: Vec<SourceLine>,
pub underline: Option<Underline>,
}
impl AnnotatedRegion {
pub(super) fn single(source_lines: Vec<SourceLine>, underline: Option<Underline>) -> Vec<Self> {
vec![Self {
source_lines,
underline,
}]
}
}
pub(super) struct Diagnostic {
pub severity: Severity,
pub slug: &'static str,
pub summary: String,
pub span: Span,
pub fn_name: Option<String>,
pub intent: Option<String>,
pub fields: Vec<(&'static str, String)>,
pub conflict: Option<String>,
pub repair_primary: Option<String>,
pub repair_alternatives: Vec<String>,
pub repair_example: Option<String>,
pub regions: Vec<AnnotatedRegion>,
}
impl Diagnostic {
pub fn is_warning(&self) -> bool {
matches!(self.severity, Severity::Warning)
}
pub fn render(&self, verbose: bool) -> String {
let mut out = String::new();
let tag = match self.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Fail => "fail",
};
let header_text = format!("{}[{}]: {}", tag, self.slug, self.summary);
let header = match self.severity {
Severity::Error | Severity::Fail => header_text.red().bold().to_string(),
Severity::Warning => header_text.yellow().bold().to_string(),
};
let _ = writeln!(out, "{}", header);
let at_label = "at:".blue().to_string();
let _ = writeln!(
out,
" {} {}:{}:{}",
at_label, self.span.file, self.span.line, self.span.col
);
if let Some(ref fn_name) = self.fn_name {
let key = "in-fn:".blue().to_string();
let _ = writeln!(out, " {} {}", key, fn_name);
}
if verbose && let Some(ref intent) = self.intent {
let key = "intent:".blue().to_string();
let _ = writeln!(out, " {} {}", key, intent.dimmed());
}
let is_error = matches!(self.severity, Severity::Error | Severity::Fail);
if is_error && let Some(ref conflict) = self.conflict {
let key = "conflict:".blue().to_string();
let _ = writeln!(out, " {} {}", key, conflict);
}
let field_limit = if verbose {
self.fields.len() } else if is_error {
4
} else {
2
};
for (key, value) in self.fields.iter().take(field_limit) {
let colored_key = format!("{}:", key).blue().to_string();
let _ = writeln!(out, " {} {}", colored_key, value);
}
if let Some(ref repair) = self.repair_primary {
let key = "repair:".blue().to_string();
let _ = writeln!(out, " {} {}", key, repair.cyan());
}
if verbose {
for alt in &self.repair_alternatives {
let key = "repair.alt:".blue().to_string();
let _ = writeln!(out, " {} {}", key, alt.cyan());
}
}
if verbose && let Some(ref example) = self.repair_example {
let key = "repair.example:".blue().to_string();
let _ = writeln!(out, " {} {}", key, example.cyan());
}
let skip_snippet = matches!(
self.slug,
"missing-verify" | "verify-effectful" | "missing-description"
);
let show_source = (is_error || verbose) && !skip_snippet;
let has_source = self.regions.iter().any(|r| !r.source_lines.is_empty());
if show_source && has_source {
let max_num = self
.regions
.iter()
.flat_map(|r| r.source_lines.iter().map(|sl| sl.line_num))
.max()
.unwrap_or(0);
let gutter_width = format!("{}", max_num).len();
let gutter_pad: String = " ".repeat(gutter_width);
let _ = writeln!(out, " {} {}", gutter_pad, "|".blue());
let mut last_emitted: Option<usize> = None;
for region in &self.regions {
if let Some(first_sl) = region.source_lines.first()
&& let Some(last) = last_emitted
&& first_sl.line_num > last + 1
{
let _ = writeln!(out, " {}", "...".blue());
}
for sl in ®ion.source_lines {
if let Some(last) = last_emitted
&& sl.line_num <= last
{
continue;
}
let num_str = format!("{:>width$}", sl.line_num, width = gutter_width);
let _ = writeln!(out, " {} {} {}", num_str.dimmed(), "|".blue(), sl.text);
last_emitted = Some(sl.line_num);
}
if let Some(ref ul) = region.underline {
let pad: String = " ".repeat(ul.col.saturating_sub(1));
let carets: String = "^".repeat(ul.len.max(1));
let colored_carets = match self.severity {
Severity::Error | Severity::Fail => carets.red().to_string(),
Severity::Warning => carets.yellow().to_string(),
};
let _ = writeln!(
out,
" {} {} {}{} {}",
gutter_pad,
"|".blue(),
pad,
colored_carets,
ul.label.dimmed()
);
}
}
}
out
}
pub fn render_json(&self) -> String {
let severity_str = match self.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Fail => "fail",
};
let mut parts: Vec<String> = Vec::new();
parts.push("\"schema_version\":1".to_string());
parts.push("\"kind\":\"diagnostic\"".to_string());
parts.push(format!("\"severity\":\"{}\"", severity_str));
parts.push(format!("\"slug\":\"{}\"", self.slug));
parts.push(format!("\"summary\":{}", json_escape(&self.summary)));
parts.push(format!("\"file\":{}", json_escape(&self.span.file)));
parts.push(format!("\"line\":{}", self.span.line));
parts.push(format!("\"col\":{}", self.span.col));
if let Some(ref fn_name) = self.fn_name {
parts.push(format!("\"fn\":{}", json_escape(fn_name)));
}
if let Some(ref intent) = self.intent {
parts.push(format!("\"intent\":{}", json_escape(intent)));
}
if !self.fields.is_empty() {
let field_strs: Vec<String> = self
.fields
.iter()
.map(|(k, v)| format!("{}:{}", json_escape(k), json_escape(v)))
.collect();
parts.push(format!("\"fields\":{{{}}}", field_strs.join(",")));
}
if let Some(ref conflict) = self.conflict {
parts.push(format!("\"conflict\":{}", json_escape(conflict)));
}
if let Some(ref repair) = self.repair_primary {
parts.push(format!("\"repair\":{}", json_escape(repair)));
}
format!("{{{}}}", parts.join(","))
}
}
pub(super) fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out.push('"');
out
}
fn extract_source_lines(source: &str, line: usize, context: usize) -> Vec<SourceLine> {
let lines: Vec<&str> = source.lines().collect();
let start = line.saturating_sub(context + 1); let end = (line + context).min(lines.len()); (start..end)
.map(|i| SourceLine {
line_num: i + 1,
text: lines[i].to_string(),
})
.collect()
}
fn extract_return_type(msg: &str) -> &str {
msg.rsplit("declared return type is ").next().unwrap_or("?")
}
fn estimate_span_len(line: &str, col: usize) -> usize {
let start = col.saturating_sub(1);
if start >= line.len() {
return 1;
}
let rest = &line[start..];
let len = rest
.chars()
.take_while(|c| !c.is_whitespace() && !matches!(c, '(' | ')' | '[' | ']' | ',' | ':'))
.count();
if len == 0 { 1 } else { len }
}
fn extract_source_lines_range(source: &str, from: usize, to: usize) -> Vec<SourceLine> {
let lines: Vec<&str> = source.lines().collect();
let start = from.saturating_sub(1); let end = to.min(lines.len()); (start..end)
.map(|i| SourceLine {
line_num: i + 1,
text: lines[i].to_string(),
})
.collect()
}
fn fill_small_region_gaps(regions: &mut Vec<AnnotatedRegion>, source: &str) {
if regions.len() < 2 {
return;
}
let lines: Vec<&str> = source.lines().collect();
let mut i = 0;
while i + 1 < regions.len() {
let last_of_prev = regions[i]
.source_lines
.last()
.map(|sl| sl.line_num)
.unwrap_or(0);
let first_of_next = regions[i + 1]
.source_lines
.first()
.map(|sl| sl.line_num)
.unwrap_or(0);
if first_of_next > last_of_prev + 1 && first_of_next <= last_of_prev + 3 {
let bridge: Vec<SourceLine> = ((last_of_prev + 1)..first_of_next)
.filter_map(|ln| {
lines.get(ln.saturating_sub(1)).map(|t| SourceLine {
line_num: ln,
text: t.to_string(),
})
})
.collect();
if !bridge.is_empty() {
regions.insert(
i + 1,
AnnotatedRegion {
source_lines: bridge,
underline: None,
},
);
i += 1; }
}
i += 1;
}
}
fn find_block_header_line(source: &str, name: &str, before_line: usize) -> Option<usize> {
let needles = [
format!("fn {}", name),
format!("verify {}", name),
format!("decision {}", name),
];
let mut best: Option<usize> = None;
for (i, line) in source.lines().enumerate() {
let line_num = i + 1;
if line_num >= before_line {
break;
}
let trimmed = line.trim_start();
for needle in &needles {
if trimmed.starts_with(needle.as_str()) {
best = Some(line_num); }
}
}
best
}
fn find_preamble_end(source: &str, header_line: usize, before_line: usize) -> usize {
let mut end = header_line;
for (i, line) in source.lines().enumerate() {
let line_num = i + 1;
if line_num <= header_line {
continue;
}
if line_num >= before_line {
break;
}
let trimmed = line.trim_start();
if trimmed.starts_with('?')
|| trimmed.starts_with('!')
|| trimmed.starts_with('"')
|| trimmed.starts_with('[')
|| trimmed.is_empty()
|| (line.starts_with(" ") && trimmed.contains(" = "))
{
end = line_num;
} else {
break;
}
}
end
}
fn find_precise_span(source_line: &str, summary: &str) -> Option<(usize, usize)> {
let search_after_arrow = summary.contains("right side") || summary.contains("=>");
for quote in ['`', '\''] {
if let Some(start_offset) = summary.find(quote) {
let start = start_offset + 1;
if let Some(end_offset) = summary[start..].find(quote) {
let needle = &summary[start..start + end_offset];
if !needle.is_empty() {
let search_region = if search_after_arrow {
source_line
.find("=>")
.map(|arrow_pos| arrow_pos + 2)
.unwrap_or(0)
} else {
0
};
if let Some(pos) = source_line[search_region..].find(needle) {
return Some((search_region + pos + 1, needle.len()));
}
}
}
}
}
None
}
pub(super) fn from_type_error(te: &TypeError, source: &str, file: &str) -> Diagnostic {
let msg = &te.message;
let line = te.line;
let col = te.col;
let (slug, conflict, fields, repair) = classify_type_error(msg);
let source_line_text = source
.lines()
.nth(line.saturating_sub(1))
.unwrap_or_default();
let (ul_col, ul_len) = if col > 0 {
(col, estimate_span_len(source_line_text, col))
} else {
let indent = source_line_text.len() - source_line_text.trim_start().len();
(indent + 1, source_line_text.trim().len())
};
let (primary_col, primary_len, primary_label) = if te.secondary.is_some() {
if let Some(arrow_pos) = source_line_text.find("-> ") {
let after_arrow = &source_line_text[arrow_pos + 3..];
let ret_type_len = after_arrow
.chars()
.take_while(|c| {
c.is_alphanumeric()
|| *c == '<'
|| *c == '>'
|| *c == ','
|| *c == ' '
|| *c == '.'
})
.count();
let ret_type_len = after_arrow[..ret_type_len].trim_end().len();
(
arrow_pos + 4,
ret_type_len.max(1),
format!("declared {}", extract_return_type(msg)),
)
} else {
(ul_col, ul_len, String::new())
}
} else {
(ul_col, ul_len, String::new())
};
let primary_underline = if primary_len > 0 {
Some(Underline {
col: primary_col,
len: primary_len,
label: primary_label,
})
} else {
None
};
let mut regions = vec![AnnotatedRegion {
source_lines: extract_source_lines(source, line, 0),
underline: primary_underline,
}];
if let Some(ref sec) = te.secondary
&& sec.line != line
{
let sec_source_text = source
.lines()
.nth(sec.line.saturating_sub(1))
.unwrap_or_default();
let (sec_col, sec_len) = if sec.col > 0 {
(sec.col, estimate_span_len(sec_source_text, sec.col))
} else {
let indent = sec_source_text.len() - sec_source_text.trim_start().len();
(indent + 1, sec_source_text.trim().len())
};
regions.push(AnnotatedRegion {
source_lines: extract_source_lines(source, sec.line, 0),
underline: Some(Underline {
col: sec_col,
len: sec_len,
label: sec.label.clone(),
}),
});
}
regions.sort_by_key(|r| r.source_lines.first().map(|sl| sl.line_num).unwrap_or(0));
fill_small_region_gaps(&mut regions, source);
Diagnostic {
severity: Severity::Error,
slug,
summary: msg.to_string(),
span: Span {
file: file.to_string(),
line,
col: ul_col,
},
fn_name: None,
intent: None,
fields,
conflict,
repair_primary: repair,
repair_alternatives: Vec::new(),
repair_example: None,
regions,
}
}
pub(super) fn unused_binding_diagnostic(
binding: &str,
fn_name: &str,
line: usize,
source: &str,
file: &str,
) -> Diagnostic {
Diagnostic {
severity: Severity::Warning,
slug: "unused-binding",
summary: format!("Unused binding '{}' in function '{}'", binding, fn_name),
span: Span {
file: file.to_string(),
line,
col: 0,
},
fn_name: Some(fn_name.to_string()),
intent: None,
fields: vec![("binding", binding.to_string())],
conflict: None,
repair_primary: Some(format!("Remove the binding or prefix with _: _{}", binding)),
repair_alternatives: Vec::new(),
repair_example: None,
regions: AnnotatedRegion::single(extract_source_lines(source, line, 0), None),
}
}
pub(super) fn missing_verify_diagnostic(
fn_name: &str,
line: usize,
_intent: Option<&str>,
source: &str,
file: &str,
) -> Diagnostic {
Diagnostic {
severity: Severity::Warning,
slug: "missing-verify",
summary: format!("Function '{}' has no verify block", fn_name),
span: Span {
file: file.to_string(),
line,
col: 0,
},
fn_name: Some(fn_name.to_string()),
intent: _intent.map(|s| s.to_string()),
fields: Vec::new(),
conflict: None,
repair_primary: Some(format!("Add a verify block: verify {}:", fn_name)),
repair_alternatives: Vec::new(),
repair_example: None,
regions: AnnotatedRegion::single(extract_source_lines(source, line, 0), None),
}
}
pub(super) fn effect_violation_diagnostic(
msg: &str,
line: usize,
col: usize,
source: &str,
file: &str,
) -> Diagnostic {
Diagnostic {
severity: Severity::Error,
slug: "effect-violation",
summary: msg.to_string(),
span: Span {
file: file.to_string(),
line,
col,
},
fn_name: None,
intent: None,
fields: Vec::new(),
conflict: Some(msg.to_string()),
repair_primary: Some("Declare missing effects with ! [Effect] on the function".to_string()),
repair_alternatives: Vec::new(),
repair_example: None,
regions: AnnotatedRegion::single(
extract_source_lines(source, line, 1),
if col > 0 {
let source_line_text = source
.lines()
.nth(line.saturating_sub(1))
.unwrap_or_default();
Some(Underline {
col,
len: estimate_span_len(source_line_text, col),
label: String::new(),
})
} else {
None
},
),
}
}
pub(super) fn from_check_finding(
severity: Severity,
finding: &aver::checker::CheckFinding,
source: &str,
file: &str,
) -> Diagnostic {
let (slug, repair) = classify_finding(&finding.message);
let fn_name = finding
.fn_name
.clone()
.or_else(|| extract_fn_name_from_finding(&finding.message));
let summary = if repair.is_some() {
finding
.message
.split_once(" — ")
.or_else(|| finding.message.split_once(" -- "))
.map(|(s, _)| s.to_string())
.unwrap_or_else(|| finding.message.clone())
} else {
finding.message.clone()
};
let source_line_text = source
.lines()
.nth(finding.line.saturating_sub(1))
.unwrap_or_default();
let (col, span_len) = find_precise_span(source_line_text, &summary).unwrap_or_else(|| {
let indent = source_line_text.len() - source_line_text.trim_start().len();
(indent + 1, source_line_text.trim().len())
});
let primary_underline = if span_len > 0 {
Some(Underline {
col,
len: span_len,
label: String::new(),
})
} else {
None
};
let mut regions = vec![AnnotatedRegion {
source_lines: extract_source_lines(source, finding.line, 0),
underline: primary_underline,
}];
for extra in &finding.extra_spans {
let extra_source_line = source
.lines()
.nth(extra.line.saturating_sub(1))
.unwrap_or_default();
let (extra_col, extra_len) = if extra.col > 0 && extra.len > 0 {
(extra.col, extra.len)
} else {
find_precise_span(extra_source_line, &extra.label).unwrap_or_else(|| {
let indent = extra_source_line.len() - extra_source_line.trim_start().len();
(indent + 1, extra_source_line.trim().len())
})
};
regions.push(AnnotatedRegion {
source_lines: extract_source_lines(source, extra.line, 0),
underline: Some(Underline {
col: extra_col,
len: extra_len,
label: extra.label.clone(),
}),
});
}
let finding_is_header = source_line_text.trim_start().starts_with("fn ")
|| source_line_text.trim_start().starts_with("verify ")
|| source_line_text.trim_start().starts_with("decision ");
if !finding_is_header
&& let Some(ref name) = fn_name
&& let Some(header_line) = find_block_header_line(source, name, finding.line)
&& header_line < finding.line
{
let fn_line = header_line;
let preamble_end = find_preamble_end(source, fn_line, finding.line);
let capped_end = preamble_end.min(fn_line + 3); let header_lines = extract_source_lines_range(source, fn_line, capped_end);
if !header_lines.is_empty() {
regions.insert(
0,
AnnotatedRegion {
source_lines: header_lines,
underline: None,
},
);
}
}
regions.sort_by_key(|r| r.source_lines.first().map(|sl| sl.line_num).unwrap_or(0));
fill_small_region_gaps(&mut regions, source);
Diagnostic {
severity,
slug,
summary,
span: Span {
file: file.to_string(),
line: finding.line,
col,
},
fn_name,
intent: None,
fields: Vec::new(),
conflict: None,
repair_primary: repair,
repair_alternatives: Vec::new(),
repair_example: None,
regions,
}
}
fn classify_finding(msg: &str) -> (&'static str, Option<String>) {
if msg.contains("has effects") && msg.contains("verify blocks are for pure") {
(
"verify-effectful",
Some(
"Remove verify block; test via `aver run --record` + `aver replay --test`"
.to_string(),
),
)
} else if msg.contains("no verify block") {
(
"missing-verify",
Some("Add a verify block with representative test cases".to_string()),
)
} else if msg.contains("no description") {
(
"missing-description",
Some("Add a ? \"description\" line after the function signature".to_string()),
)
} else if msg.contains("non-tail recursion") {
(
"non-tail-recursion",
Some("Convert to accumulator style for tail-call optimization".to_string()),
)
} else if msg.contains("unused expose") || msg.contains("not used by") {
("unused-expose", None)
} else if msg.contains("verify coverage") || msg.contains("verify case") {
("verify-coverage", None)
} else if msg.contains("verify law") {
("verify-law", None)
} else if msg.contains("List.len") && msg.contains("traverses the entire list") {
("perf-list-len", split_repair(msg))
} else if msg.contains("string concatenation") && msg.contains("recursive call") {
("perf-string-concat", split_repair(msg))
} else if msg.contains("nested `match") {
("perf-nested-match", split_repair(msg))
} else if msg.contains("recomputed every recursive call") {
("perf-loop-invariant", split_repair(msg))
} else if msg.contains("computed in both the match condition") {
("cse-match", split_repair(msg))
} else if msg.contains("computed") && msg.contains("times in this function") {
("cse-duplicate", split_repair(msg))
} else if msg.contains("Independent product branches")
&& msg.contains("potentially conflicting effects")
{
("independence-hazard", split_repair(msg))
} else if msg.contains("unused effect") {
(
"unused-effect",
Some("Remove unused effects from the ! [...] declaration".to_string()),
)
} else if msg.contains("unknown impact symbol") {
("unknown-impact", split_repair(msg))
} else if msg.contains("must not call") && msg.contains("on the right side") {
("verify-rhs", None)
} else if msg.contains("consider granular") {
("effect-granularity", split_repair(msg))
} else if msg.contains("verify examples") || msg.contains("verify case") {
("verify-coverage", None)
} else {
("check", None)
}
}
fn split_repair(msg: &str) -> Option<String> {
msg.split_once(" — ")
.or_else(|| msg.split_once(" -- "))
.map(|(_, repair)| {
let mut r = repair.to_string();
if let Some(first) = r.get_mut(0..1) {
first.make_ascii_uppercase();
}
r
})
}
fn extract_fn_name_from_finding(msg: &str) -> Option<String> {
if let Some(start) = msg.find('\'')
&& let Some(end) = msg[start + 1..].find('\'')
{
return Some(msg[start + 1..start + 1 + end].to_string());
}
None
}
type Classification = (
&'static str,
Option<String>,
Vec<(&'static str, String)>,
Option<String>,
);
fn classify_type_error(msg: &str) -> Classification {
if let Some(rest) = msg.strip_prefix("Type mismatch:") {
let rest = rest.trim();
let mut fields = Vec::new();
let mut expected = String::new();
let mut got = String::new();
if let Some((exp, g)) = rest.split_once(", got ") {
expected = exp
.strip_prefix("expected ")
.unwrap_or(exp)
.trim()
.to_string();
got = g.trim().to_string();
fields.push(("contract.expected", expected.clone()));
fields.push(("observed.actual", got.clone()));
}
let repair = if !expected.is_empty() && !got.is_empty() {
Some(format!("Change the expression to produce {}", expected))
} else {
None
};
return ("type-mismatch", Some(msg.to_string()), fields, repair);
}
if msg.starts_with("Unknown identifier") || msg.starts_with("Unknown function") {
return (
"unknown-ident",
None,
Vec::new(),
Some("Check the spelling or add the missing import".to_string()),
);
}
if msg.contains("expects") && msg.contains("argument") {
return (
"arity-mismatch",
Some(msg.to_string()),
Vec::new(),
Some("Adjust the number of arguments".to_string()),
);
}
if msg.contains("effect") && (msg.contains("not declared") || msg.contains("not allowed")) {
return (
"effect-violation",
Some(msg.to_string()),
Vec::new(),
Some("Add the missing effect to the function's ! [...] declaration".to_string()),
);
}
("type-error", None, Vec::new(), None)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn verify_mismatch_diagnostic(
file: &str,
source: &str,
block_name: &str,
case_expr: &str,
expected: &str,
actual: &str,
line: usize,
col: usize,
is_law: bool,
law_context: Option<&aver::checker::VerifyLawContext>,
) -> Diagnostic {
let summary = if is_law {
"law violated"
} else {
"assertion failed"
};
let mut fields: Vec<(&'static str, String)> = vec![
("block", block_name.to_string()),
("case", case_expr.to_string()),
("expected", expected.to_string()),
("actual", actual.to_string()),
];
if let Some(lctx) = law_context {
for (name, val) in &lctx.givens {
fields.push(("given", format!("{} = {}", name, val)));
}
fields.push(("law", lctx.law_expr.clone()));
}
Diagnostic {
severity: Severity::Fail,
slug: "verify-mismatch",
summary: summary.to_string(),
span: Span {
file: file.to_string(),
line,
col,
},
fn_name: None,
intent: None,
fields,
conflict: None,
repair_primary: None,
repair_alternatives: Vec::new(),
repair_example: None,
regions: AnnotatedRegion::single(
extract_source_lines(source, line, 0),
Some(Underline {
col,
len: source
.lines()
.nth(line.saturating_sub(1))
.map(|l| l.trim().len())
.unwrap_or(1)
.max(1),
label: "verify-mismatch".to_string(),
}),
),
}
}
pub(super) fn verify_runtime_error_diagnostic(
file: &str,
source: &str,
block_name: &str,
case_expr: &str,
error: &str,
line: usize,
col: usize,
) -> Diagnostic {
let fields: Vec<(&'static str, String)> = vec![
("block", block_name.to_string()),
("case", case_expr.to_string()),
("error", error.to_string()),
];
Diagnostic {
severity: Severity::Fail,
slug: "verify-runtime-error",
summary: "case aborted".to_string(),
span: Span {
file: file.to_string(),
line,
col,
},
fn_name: None,
intent: None,
fields,
conflict: None,
repair_primary: None,
repair_alternatives: Vec::new(),
repair_example: None,
regions: AnnotatedRegion::single(
extract_source_lines(source, line, 0),
Some(Underline {
col,
len: source
.lines()
.nth(line.saturating_sub(1))
.map(|l| l.trim().len())
.unwrap_or(1)
.max(1),
label: "verify-runtime-error".to_string(),
}),
),
}
}
pub(super) fn verify_unexpected_err_diagnostic(
file: &str,
source: &str,
block_name: &str,
case_expr: &str,
err_repr: &str,
line: usize,
col: usize,
) -> Diagnostic {
let fields: Vec<(&'static str, String)> = vec![
("block", block_name.to_string()),
("case", case_expr.to_string()),
("error", err_repr.to_string()),
];
Diagnostic {
severity: Severity::Fail,
slug: "verify-unexpected-err",
summary: "error propagated from ?".to_string(),
span: Span {
file: file.to_string(),
line,
col,
},
fn_name: None,
intent: None,
fields,
conflict: None,
repair_primary: None,
repair_alternatives: Vec::new(),
repair_example: None,
regions: AnnotatedRegion::single(
extract_source_lines(source, line, 0),
Some(Underline {
col,
len: source
.lines()
.nth(line.saturating_sub(1))
.map(|l| l.trim().len())
.unwrap_or(1)
.max(1),
label: "verify-unexpected-err".to_string(),
}),
),
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn replay_output_mismatch_diagnostic(
program_file: &str,
recording_path: &str,
expected: &str,
actual: &str,
diff_path: Option<&str>,
entry_fn: &str,
entry_line: usize,
recording_output_line: usize,
) -> Diagnostic {
let recording_ref = if recording_output_line > 0 {
format!("{}:{}", recording_path, recording_output_line)
} else {
format!("{}:$.output", recording_path)
};
let mut fields: Vec<(&'static str, String)> = vec![
("recording", recording_ref),
("expected", expected.to_string()),
("actual", actual.to_string()),
];
if let Some(dp) = diff_path {
let label = if dp == "$" {
"$ (root)".to_string()
} else {
dp.to_string()
};
fields.push(("diff", label));
}
Diagnostic {
severity: Severity::Fail,
slug: "replay-output-mismatch",
summary: "recorded output differs".to_string(),
span: Span {
file: program_file.to_string(),
line: entry_line,
col: 0,
},
fn_name: if entry_fn.is_empty() {
None
} else {
Some(entry_fn.to_string())
},
intent: None,
fields,
conflict: None,
repair_primary: None,
repair_alternatives: Vec::new(),
repair_example: None,
regions: AnnotatedRegion::single(vec![], None),
}
}
pub(super) fn replay_effect_error_diagnostic(
program_file: &str,
recording_path: &str,
error: &str,
entry_fn: &str,
entry_line: usize,
) -> Diagnostic {
let fields: Vec<(&'static str, String)> = vec![
("recording", recording_path.to_string()),
("error", error.to_string()),
];
Diagnostic {
severity: Severity::Fail,
slug: "replay-error",
summary: "replay failed".to_string(),
span: Span {
file: program_file.to_string(),
line: entry_line,
col: 0,
},
fn_name: if entry_fn.is_empty() {
None
} else {
Some(entry_fn.to_string())
},
intent: None,
fields,
conflict: None,
repair_primary: None,
repair_alternatives: Vec::new(),
repair_example: None,
regions: AnnotatedRegion::single(vec![], None),
}
}
#[cfg(test)]
mod tests {
use super::classify_finding;
#[test]
fn classifies_independence_hazard_warning() {
let (slug, repair) = classify_finding(
"Independent product branches 1 and 2 use potentially conflicting effects [Console.print, Console.error] (shared terminal/output hazard) — independent products may reorder or overlap these effects; keep them sequential or suppress with [[check.suppress]] reason if this independence is intentional",
);
assert_eq!(slug, "independence-hazard");
assert!(repair.is_some());
}
}