use tower_lsp_server::ls_types::{
Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, Location, NumberOrString,
Position, Range, Uri,
};
use aver::ast::{TopLevel, VerifyKind};
use aver::checker::merge_verify_blocks;
use aver::diagnostics::{
AnalyzeOptions, Diagnostic as CanonicalDiagnostic, Severity as CanonicalSeverity,
analyze_source,
};
use aver::lexer::{Lexer, LexerError};
use aver::parser::Parser;
pub fn diagnose(source: &str, base_dir: Option<&str>, uri: Option<&Uri>) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let mut lexer = Lexer::new(source);
let tokens = match lexer.tokenize() {
Ok(tokens) => tokens,
Err(LexerError::Error { msg, line, col }) => {
diagnostics.push(point_diagnostic(line, col, DiagnosticSeverity::ERROR, msg));
return diagnostics;
}
};
let mut parser = Parser::new(tokens);
let items = match parser.parse() {
Ok(items) => items,
Err(aver::parser::ParseError::Error { msg, line, col }) => {
diagnostics.push(point_diagnostic(line, col, DiagnosticSeverity::ERROR, msg));
return diagnostics;
}
};
let file_label = uri
.map(|u| u.to_string())
.unwrap_or_else(|| "<lsp>".to_string());
let mut opts = AnalyzeOptions::new(file_label);
if let Some(dir) = base_dir {
opts.module_base_dir = Some(dir.to_string());
}
opts.include_verify_run = true;
let report = analyze_source(source, &opts);
for diag in &report.diagnostics {
diagnostics.push(to_lsp_diagnostic(diag, uri));
}
diagnostics.extend(verify_hygiene_diagnostics(&items));
diagnostics
}
fn to_lsp_diagnostic(d: &CanonicalDiagnostic, uri: Option<&Uri>) -> Diagnostic {
let severity = match d.severity {
CanonicalSeverity::Error | CanonicalSeverity::Fail => DiagnosticSeverity::ERROR,
CanonicalSeverity::Warning => DiagnosticSeverity::WARNING,
CanonicalSeverity::Hint => DiagnosticSeverity::HINT,
};
let line = d.span.line.saturating_sub(1) as u32;
let (start_char, end_char) = d
.regions
.first()
.and_then(|r| r.underline.as_ref())
.map(|ul| (ul.col.saturating_sub(1), ul.col + ul.len.max(1) - 1))
.unwrap_or_else(|| {
let start = d.span.col.saturating_sub(1);
(start, start.max(start + 1))
});
let related = if d.related.is_empty() {
None
} else {
uri.map(|u| {
d.related
.iter()
.map(|rel| DiagnosticRelatedInformation {
location: Location {
uri: u.clone(),
range: Range {
start: Position {
line: rel.span.line.saturating_sub(1) as u32,
character: rel.span.col.saturating_sub(1) as u32,
},
end: Position {
line: rel.span.line.saturating_sub(1) as u32,
character: (rel.span.col + 1) as u32,
},
},
},
message: rel.label.clone(),
})
.collect()
})
};
let mut message = d.summary.clone();
for (key, value) in &d.fields {
message.push_str(&format!("\n {}: {}", key, value));
}
if let Some(repair) = d.repair.primary.as_deref() {
message.push_str(&format!("\n\nrepair: {}", repair));
}
Diagnostic {
range: Range {
start: Position {
line,
character: start_char as u32,
},
end: Position {
line,
character: end_char as u32,
},
},
severity: Some(severity),
code: Some(NumberOrString::String(d.slug.to_string())),
source: Some("aver".to_string()),
message,
related_information: related,
..Default::default()
}
}
fn point_diagnostic(
line: usize,
col: usize,
severity: DiagnosticSeverity,
message: String,
) -> Diagnostic {
Diagnostic {
range: Range {
start: Position {
line: line.saturating_sub(1) as u32,
character: col.saturating_sub(1) as u32,
},
end: Position {
line: line.saturating_sub(1) as u32,
character: col as u32,
},
},
severity: Some(severity),
source: Some("aver".to_string()),
message,
..Default::default()
}
}
fn verify_hygiene_diagnostics(items: &[TopLevel]) -> Vec<Diagnostic> {
let verify_by_fn = merge_verify_blocks(items).into_iter().fold(
std::collections::HashMap::new(),
|mut acc, vb| {
acc.entry(vb.fn_name.clone())
.or_insert_with(Vec::new)
.push(vb);
acc
},
);
let mut diagnostics = Vec::new();
for item in items {
let TopLevel::FnDef(fd) = item else {
continue;
};
let Some(blocks) = verify_by_fn.get(&fd.name) else {
continue;
};
let case_count: usize = blocks
.iter()
.filter(|vb| matches!(vb.kind, VerifyKind::Cases))
.map(|vb| vb.cases.len())
.sum();
let law_count = blocks
.iter()
.filter(|vb| matches!(vb.kind, VerifyKind::Law(_)))
.count();
if case_count > 0 && law_count == 0 {
diagnostics.push(hint_at_line(
fd.line,
format!(
"Function '{}' has verify examples but no law; add one invariant to lock behavior",
fd.name
),
));
}
if case_count == 0 && law_count > 0 {
diagnostics.push(hint_at_line(
fd.line,
format!(
"Function '{}' has verify laws but no concrete examples; add a few examples for readability",
fd.name
),
));
}
}
diagnostics
}
fn hint_at_line(line: usize, message: String) -> Diagnostic {
let line = line.saturating_sub(1) as u32;
Diagnostic {
range: Range {
start: Position { line, character: 0 },
end: Position { line, character: 0 },
},
severity: Some(DiagnosticSeverity::HINT),
source: Some("aver-lsp".to_string()),
message,
..Default::default()
}
}
#[cfg(test)]
mod tests {
use tower_lsp_server::ls_types::DiagnosticSeverity;
use super::diagnose;
#[test]
fn diagnostics_show_verify_mismatch() {
let source = r#"module Demo
intent = "demo"
fn add(a: Int, b: Int) -> Int
? "adds"
a + b
verify add
add(1, 2) => 3
add(2, 3) => 999
"#;
let diagnostics = diagnose(source, None, None);
assert!(
diagnostics.iter().any(|diag| {
diag.severity == Some(DiagnosticSeverity::ERROR)
&& matches!(
&diag.code,
Some(tower_lsp_server::ls_types::NumberOrString::String(s))
if s == "verify-mismatch"
)
&& diag.message.contains("999")
&& diag.message.contains("5")
}),
"expected verify-mismatch diagnostic, got: {:?}",
diagnostics
.iter()
.map(|d| (d.code.clone(), d.message.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn diagnostics_no_verify_error_when_all_pass() {
let source = r#"module Demo
intent = "demo"
fn add(a: Int, b: Int) -> Int
? "adds"
a + b
verify add
add(1, 2) => 3
add(2, 3) => 5
"#;
let diagnostics = diagnose(source, None, None);
assert!(
!diagnostics
.iter()
.any(|diag| diag.message.contains("verify mismatch")),
"should not have verify mismatch, got: {:?}",
diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}
#[test]
fn diagnostics_warn_when_verify_has_cases_but_no_law() {
let source = r#"module Demo
intent =
"demo"
fn add1(x: Int) -> Int
x + 1
verify add1
add1(1) => 2
"#;
let diagnostics = diagnose(source, None, None);
assert!(diagnostics.iter().any(|diag| {
diag.severity == Some(DiagnosticSeverity::HINT)
&& diag.message.contains("verify examples but no law")
}));
}
#[test]
fn diagnostics_warn_when_verify_has_law_but_no_examples() {
let source = r#"module Demo
intent =
"demo"
fn add1(x: Int) -> Int
x + 1
fn add1Spec(x: Int) -> Int
x + 1
verify add1 law add1Spec
given x: Int = 0..1
add1(x) => add1Spec(x)
"#;
let diagnostics = diagnose(source, None, None);
assert!(diagnostics.iter().any(|diag| {
diag.severity == Some(DiagnosticSeverity::HINT)
&& diag
.message
.contains("verify laws but no concrete examples")
}));
}
#[test]
fn diagnostics_carry_slug_as_code() {
let source = r#"module Demo
fn bad() -> Int
? "type mismatch"
"string"
"#;
let diagnostics = diagnose(source, None, None);
let has_type_error_with_code = diagnostics.iter().any(|d| {
d.severity == Some(DiagnosticSeverity::ERROR)
&& matches!(
&d.code,
Some(tower_lsp_server::ls_types::NumberOrString::String(slug))
if slug.starts_with("type-")
)
});
assert!(
has_type_error_with_code,
"expected a type-* diagnostic carrying its slug in the `code` field; got: {:?}",
diagnostics
.iter()
.map(|d| (d.severity, d.code.clone(), d.message.clone()))
.collect::<Vec<_>>()
);
}
}