use clap::{Args, ValueEnum};
use rossi::{
Component, ParseError, parse_components, parse_components_with_recovery,
parse_zip_with_recovery,
};
use rossi_build::project::discover_projects;
use rossi_build::{Diagnostic, Project, ProjectComponent, RuleId, Severity, error::ProjectError};
use serde::{Serialize, Serializer};
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use crate::commands::eventb_io;
use crate::commands::sarif;
#[derive(Args)]
pub struct ValidateArgs {
#[arg(required = true, value_name = "FILE")]
files: Vec<PathBuf>,
#[arg(short, long, value_enum, default_value = "text")]
format: OutputFormat,
#[arg(short, long)]
quiet: bool,
#[arg(short, long)]
continue_on_error: bool,
#[arg(long)]
no_semantic: bool,
#[arg(long)]
no_lints: bool,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long)]
deny_warnings: bool,
#[arg(long)]
show_info: bool,
#[arg(long, value_name = "NAME")]
sarif_category: Option<String>,
#[arg(long, value_name = "PATH")]
stdin_filename: Option<String>,
}
#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
enum OutputFormat {
Text,
Json,
Sarif,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct Region {
pub start_line: usize,
pub start_column: usize,
pub end_line: usize,
pub end_column: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum InputKind {
File,
Directory,
Archive,
}
#[derive(Debug, Clone, Copy)]
struct Input<'a> {
path: &'a Path,
kind: InputKind,
}
impl<'a> Input<'a> {
fn file(path: &'a Path) -> Self {
Self {
path,
kind: InputKind::File,
}
}
fn directory(path: &'a Path) -> Self {
Self {
path,
kind: InputKind::Directory,
}
}
fn archive(path: &'a Path) -> Self {
Self {
path,
kind: InputKind::Archive,
}
}
}
#[derive(Debug, Serialize)]
pub struct ValidationResult {
pub file: PathBuf,
pub input: InputKind,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub component_type: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub component_name: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
serialize_with = "ser_severity"
)]
pub severity: Option<Severity>,
#[serde(
skip_serializing_if = "Option::is_none",
serialize_with = "ser_rule_id"
)]
pub rule_id: Option<RuleId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<Region>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proof_summary: Option<ProofSummaryJson>,
}
#[derive(Debug, Serialize)]
pub struct ProofSummaryJson {
pub total: usize,
pub discharged: usize,
pub reviewed: usize,
pub pending: usize,
pub unattempted: usize,
pub broken: usize,
}
impl From<rossi_build::proofs::ProofSummary> for ProofSummaryJson {
fn from(s: rossi_build::proofs::ProofSummary) -> Self {
ProofSummaryJson {
total: s.total,
discharged: s.discharged,
reviewed: s.reviewed,
pending: s.pending,
unattempted: s.unattempted,
broken: s.broken,
}
}
}
impl ValidationResult {
pub fn joined_path(&self, archive_separator: &str) -> String {
let base = self.file.display().to_string();
let Some(inner) = &self.inner_filename else {
return base;
};
match self.input {
InputKind::Archive => format!("{base}{archive_separator}{inner}"),
InputKind::Directory | InputKind::File => {
format!("{}/{inner}", base.trim_end_matches(['/', '\\']))
}
}
}
pub(crate) fn portable_path(&self) -> String {
self.joined_path("!/").replace('\\', "/")
}
}
fn ser_severity<S: Serializer>(value: &Option<Severity>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_some(&value.expect("skipped by serde when None").to_string())
}
fn ser_rule_id<S: Serializer>(value: &Option<RuleId>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_some(value.expect("skipped by serde when None").code())
}
enum Report {
Console,
File(io::BufWriter<fs::File>),
}
impl Report {
fn open(path: Option<&Path>) -> io::Result<Self> {
match path {
Some(path) => Ok(Self::File(io::BufWriter::new(fs::File::create(path)?))),
None => Ok(Self::Console),
}
}
fn line(&mut self, line: &str, is_error: bool) -> io::Result<()> {
match self {
Self::Console if is_error => {
eprintln!("{line}");
Ok(())
}
Self::Console => {
println!("{line}");
Ok(())
}
Self::File(out) => writeln!(out, "{line}"),
}
}
fn structured(
&mut self,
write: impl FnOnce(&mut dyn Write) -> io::Result<()>,
) -> io::Result<()> {
match self {
Self::Console => write(&mut io::stdout().lock()),
Self::File(out) => write(out),
}
}
fn flush(&mut self) -> io::Result<()> {
match self {
Self::Console => Ok(()),
Self::File(out) => out.flush(),
}
}
}
pub fn run(cli: ValidateArgs) -> ExitCode {
if let Err(e) = eventb_io::stdin_is_sole_input(&cli.files) {
eprintln!("rossi validate: {e}");
return ExitCode::from(2);
}
if cli.sarif_category.is_some() && cli.format != OutputFormat::Sarif {
eprintln!("rossi validate: --sarif-category requires --format sarif");
return ExitCode::from(2);
}
let mut report = match Report::open(cli.output.as_deref()) {
Ok(report) => report,
Err(e) => {
let path = cli.output.as_deref().unwrap_or(Path::new("-")).display();
eprintln!("rossi validate: failed to open {path}: {e}");
return ExitCode::from(1);
}
};
let mut results = Vec::new();
let mut all_success = true;
let mut stopped_early = false;
let mut output_result = Ok(());
let aggregating_format = matches!(cli.format, OutputFormat::Json | OutputFormat::Sarif);
for file in &cli.files {
let mut input_failed = false;
for result in validate_file(file, &cli) {
let gate_failed = row_failed(&result, cli.deny_warnings);
if cli.format == OutputFormat::Text && (!cli.quiet || gate_failed) {
let (line, is_error) = text_line(&result);
output_result = output_result.and_then(|()| report.line(&line, is_error));
}
all_success &= !gate_failed;
input_failed |= !result.success;
results.push(result);
}
if input_failed && !cli.continue_on_error && !aggregating_format {
stopped_early = true;
break;
}
}
let validation_exit = if all_success {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
};
let output_result = output_result
.and_then(|()| match cli.format {
OutputFormat::Text if stopped_early || cli.quiet || results.len() <= 1 => Ok(()),
OutputFormat::Text => write_summary(&mut report, &results, cli.deny_warnings),
OutputFormat::Json => report.structured(|out| write_json(&results, out)),
OutputFormat::Sarif => {
report.structured(|out| sarif::emit(&results, cli.sarif_category.as_deref(), out))
}
})
.and_then(|()| report.flush());
let mut stderr = io::stderr().lock();
finish_structured_output(output_result, validation_exit, &mut stderr)
}
fn row_failed(result: &ValidationResult, deny_warnings: bool) -> bool {
!result.success || (deny_warnings && result.severity == Some(Severity::Warning))
}
fn finish_structured_output(
output_result: io::Result<()>,
validation_exit: ExitCode,
stderr: &mut impl Write,
) -> ExitCode {
match output_result {
Ok(()) => validation_exit,
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => validation_exit,
Err(e) => {
let _ = writeln!(stderr, "rossi validate: failed to write output: {e}");
ExitCode::from(1)
}
}
}
fn validate_file(file: &Path, cli: &ValidateArgs) -> Vec<ValidationResult> {
if eventb_io::is_stdin(file) {
return validate_stdin(cli);
}
if !file.exists() {
return vec![error_result(
Input::file(file),
None,
format!("File not found: {}", file.display()),
None,
)];
}
if file.is_dir() {
return validate_directory(file, cli);
}
if let Some(ext) = file.extension()
&& ext.eq_ignore_ascii_case("zip")
{
return validate_zip_file(file, cli);
}
validate_text_file(file, cli)
}
fn validate_text_file(file: &Path, cli: &ValidateArgs) -> Vec<ValidationResult> {
let source = match fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
return vec![error_result(
Input::file(file),
None,
format!("Failed to read file: {e}"),
None,
)];
}
};
validate_text_source(Input::file(file), None, &source, cli)
}
fn validate_stdin(cli: &ValidateArgs) -> Vec<ValidationResult> {
let display = Path::new(cli.stdin_filename.as_deref().unwrap_or("<stdin>"));
match eventb_io::read_stdin_to_string() {
Ok(source) => validate_text_source(Input::file(display), None, &source, cli),
Err(e) => vec![error_result(
Input::file(display),
None,
format!("Failed to read standard input: {e}"),
None,
)],
}
}
fn validate_text_source(
input: Input,
inner: Option<&str>,
source: &str,
cli: &ValidateArgs,
) -> Vec<ValidationResult> {
let inner = || inner.map(str::to_string);
match parse_components(source) {
Ok(components) => {
let mut results: Vec<ValidationResult> = components
.iter()
.map(|c| success_result(input, inner(), c))
.collect();
for component in &components {
if !cli.no_semantic {
for diag in rossi_build::duplicates::component_duplicate_diagnostics(component)
{
results.push(fold_diagnostic(input, diag, inner(), Some(source)));
}
}
if !cli.no_lints {
for diag in rossi_build::lint::run_component(component) {
results.push(fold_diagnostic(input, diag, inner(), Some(source)));
}
}
}
if cli.show_info && !cli.no_semantic && input.kind == InputKind::File {
let filename = input
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("model.eventb")
.to_string();
let project_name = input
.path
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("project");
let project = Project::new(
project_name,
components
.into_iter()
.map(|component| ProjectComponent {
filename: filename.clone(),
component,
rodin_ids: Default::default(),
source: Some(source.to_string()),
})
.collect(),
);
let (_, model) = rossi_build::build_with_model(&project);
for diag in rossi_build::wd::run(&project, &model) {
results.push(fold_diagnostic(input, diag, inner(), Some(source)));
}
}
results
}
Err(e) => {
let recovered = parse_components_with_recovery(source);
let mut results: Vec<ValidationResult> = recovered
.errors
.iter()
.filter_map(precise_formula_error)
.map(|(err, absolute_span)| {
let mut result = error_result(
input,
inner(),
format!("{err}"),
Some(rule_for_parse_error(err)),
);
result.region = absolute_span
.map(|span| span_to_region(source, span))
.or_else(|| parse_error_region(err, source));
result
})
.collect();
let only_precise_errors =
!results.is_empty() && results.len() == recovered.errors.len();
if !only_precise_errors {
let mut fallback = error_result(
input,
inner(),
format!("{e}"),
Some(RuleId::CamilleParseError),
);
fallback.region = parse_error_region(&e, source);
results.push(fallback);
}
results
}
}
}
fn precise_formula_error(err: &ParseError) -> Option<(&ParseError, Option<rossi::ast::Span>)> {
match err {
ParseError::AssignmentInPredicate { .. } | ParseError::AssignmentArityMismatch { .. } => {
Some((err, None))
}
ParseError::RecoverableError {
span: Some(recovery_span),
source: Some(source),
..
} if matches!(source.as_ref(), ParseError::AssignmentArityMismatch { .. }) => {
let absolute_span = source.span().map_or(*recovery_span, |mut span| {
span.shift(recovery_span.start);
span
});
Some((source, Some(absolute_span)))
}
_ => None,
}
}
fn validate_zip_file(file: &Path, cli: &ValidateArgs) -> Vec<ValidationResult> {
let input = Input::archive(file);
let bytes = match fs::read(file) {
Ok(b) => b,
Err(e) => {
return vec![error_result(
input,
None,
format!("Failed to read zip file: {e}"),
None,
)];
}
};
let fallback = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("project");
let projects = match discover_projects(&bytes, fallback) {
Ok(projects) => projects,
Err(_) => return validate_zip_flat_fallback(file, &bytes, fallback, cli),
};
let projects: Vec<_> = projects
.into_iter()
.filter(|p| !p.components.is_empty())
.collect();
if projects.is_empty() {
return vec![error_result(
input,
None,
"No Event-B components found in zip file".to_string(),
None,
)];
}
let multi = projects.len() > 1;
let mut results = Vec::new();
for dp in projects {
let prefix = if multi {
dp.prefix.clone()
} else {
String::new()
};
for pc in &dp.components {
results.push(success_result(
input,
Some(format!("{prefix}{}", pc.filename)),
&pc.component,
));
}
if !cli.no_semantic {
let project = dp.into_project();
fold_semantic(&project, input, cli, &prefix, &mut results);
}
}
fold_proofs(
rossi_build::proofs::check_zip_bytes(&bytes),
input,
&mut results,
);
results
}
fn validate_zip_flat_fallback(
file: &Path,
bytes: &[u8],
name: &str,
cli: &ValidateArgs,
) -> Vec<ValidationResult> {
let input = Input::archive(file);
let parse_result = parse_zip_with_recovery(bytes);
let mut results = Vec::new();
let mut had_parse_error = false;
for err in parse_result.get_errors() {
had_parse_error = true;
results.push(error_result(
input,
None,
format!("{err}"),
Some(rule_for_parse_error(err)),
));
}
if let Some(components) = parse_result.component {
if components.is_empty() && results.is_empty() {
results.push(error_result(
input,
None,
"No Event-B components found in zip file".to_string(),
None,
));
had_parse_error = true;
} else {
for named in components {
results.push(success_result(
input,
Some(named.filename),
&named.component,
));
}
}
} else if results.is_empty() {
results.push(error_result(
input,
None,
"Failed to parse zip file".to_string(),
None,
));
had_parse_error = true;
}
if !cli.no_semantic && !had_parse_error {
match Project::from_zip_bytes(name, bytes) {
Ok(project) => fold_semantic(&project, input, cli, "", &mut results),
Err(e) => results.push(error_result(
input,
None,
format!("{e}"),
rule_for_build_error(&e),
)),
}
}
fold_proofs(
rossi_build::proofs::check_zip_bytes(bytes),
input,
&mut results,
);
results
}
fn validate_directory(dir: &Path, cli: &ValidateArgs) -> Vec<ValidationResult> {
let input = Input::directory(dir);
let mut results = Vec::new();
if cli.no_semantic {
results.push(error_result(
input,
None,
"directory inputs require semantic checks; drop --no-semantic or pass a .zip / .eventb file".to_string(),
None,
));
return results;
}
match Project::from_directory(dir) {
Ok(project) => {
if project.components.is_empty() {
return vec![error_result(
input,
None,
"No Event-B components found in directory".to_string(),
None,
)];
}
for pc in &project.components {
results.push(success_result(
input,
Some(pc.filename.clone()),
&pc.component,
));
}
fold_semantic(&project, input, cli, "", &mut results);
}
Err(e) => results.extend(validate_directory_fallback(dir, &e, cli)),
}
fold_proofs(
rossi_build::proofs::check_directory(dir),
input,
&mut results,
);
results
}
fn fold_proofs(
report: rossi_build::error::Result<rossi_build::proofs::ProofReport>,
input: Input,
out: &mut Vec<ValidationResult>,
) {
let Ok(report) = report else { return };
for diag in report.diagnostics {
out.push(fold_diagnostic(input, diag, None, None));
}
if let Some(summary) = report.summary {
out.push(proof_summary_result(input, summary));
}
}
fn validate_directory_fallback(
dir: &Path,
load_error: &rossi_build::Error,
cli: &ValidateArgs,
) -> Vec<ValidationResult> {
let input = Input::directory(dir);
let load_error_row = || {
error_result(
input,
None,
format!("{load_error}"),
rule_for_build_error(load_error),
)
};
let Ok(files) = rossi_build::project::component_files(dir) else {
return vec![load_error_row()];
};
let mut results = Vec::new();
for file in &files {
let Some(filename) = file.file_name().and_then(|n| n.to_str()) else {
continue;
};
let text = match fs::read_to_string(file) {
Ok(text) => text,
Err(e) => {
results.push(error_result(
input,
Some(filename.to_string()),
format!("Failed to read file: {e}"),
None,
));
continue;
}
};
if rossi_build::project::is_xml_input(filename) {
match rossi_build::ProjectComponent::from_xml(filename, &text) {
Ok(pc) => results.push(success_result(
input,
Some(filename.to_string()),
&pc.component,
)),
Err(e) => results.push(error_result(
input,
Some(filename.to_string()),
format!("{e}"),
rule_for_build_error(&e),
)),
}
} else {
results.extend(validate_text_source(input, Some(filename), &text, cli));
}
}
if !results.iter().any(|r| !r.success) {
results.push(load_error_row());
}
results
}
fn fold_semantic(
project: &Project,
input: Input,
cli: &ValidateArgs,
prefix: &str,
out: &mut Vec<ValidationResult>,
) {
let (build, model) = rossi_build::build_with_model(project);
for diag in build.diagnostics {
out.push(fold_project_diagnostic(input, diag, project, prefix));
}
if cli.show_info {
for diag in rossi_build::wd::run(project, &model) {
out.push(fold_project_diagnostic(input, diag, project, prefix));
}
}
if !cli.no_lints {
for diag in rossi_build::lint::run(project) {
out.push(fold_project_diagnostic(input, diag, project, prefix));
}
}
}
fn success_result(input: Input, inner: Option<String>, component: &Component) -> ValidationResult {
let (component_type, component_name) = match component {
Component::Context(c) => ("Context", c.name.clone()),
Component::Machine(m) => ("Machine", m.name.clone()),
};
ValidationResult {
file: input.path.to_path_buf(),
input: input.kind,
success: true,
inner_filename: inner,
error: None,
component_type: Some(component_type),
component_name: Some(component_name),
severity: None,
rule_id: None,
origin: None,
region: None,
proof_summary: None,
}
}
fn proof_summary_result(
input: Input,
summary: rossi_build::proofs::ProofSummary,
) -> ValidationResult {
ValidationResult {
file: input.path.to_path_buf(),
input: input.kind,
success: true,
inner_filename: None,
error: None,
component_type: None,
component_name: None,
severity: None,
rule_id: None,
origin: None,
region: None,
proof_summary: Some(summary.into()),
}
}
fn fold_diagnostic(
input: Input,
diag: Diagnostic,
inner_filename: Option<String>,
source: Option<&str>,
) -> ValidationResult {
let region = diag
.span
.zip(source)
.map(|(span, src)| span_to_region(src, span));
ValidationResult {
file: input.path.to_path_buf(),
input: input.kind,
success: diag.severity != Severity::Error,
inner_filename,
error: Some(diag.message),
component_type: None,
component_name: None,
severity: Some(diag.severity),
rule_id: diag.rule_id,
origin: Some(diag.origin),
region,
proof_summary: None,
}
}
fn fold_project_diagnostic(
input: Input,
diag: Diagnostic,
project: &Project,
prefix: &str,
) -> ValidationResult {
let component = diag.origin.split('.').next().unwrap_or(&diag.origin);
let mut carriers = project
.components
.iter()
.filter(|pc| pc.component.name() == component);
let pc = carriers.next();
let pc = if carriers.next().is_some() { None } else { pc };
let inner = pc.map(|pc| format!("{prefix}{}", pc.filename));
let source = pc.and_then(|pc| pc.source.as_deref());
fold_diagnostic(input, diag, inner, source)
}
fn rule_for_parse_error(err: &ParseError) -> RuleId {
let mut inner = err;
while let ParseError::FileContext { source, .. } = inner {
inner = source;
}
match inner {
ParseError::UnexpectedXmlRoot { .. } => RuleId::XmlRootError,
ParseError::MissingXmlAttribute { .. } => RuleId::XmlAttributeError,
ParseError::NestingTooDeep { .. } => RuleId::FormulaParseError,
ParseError::MalformedAttribute { .. } => RuleId::FormulaParseError,
ParseError::IncompatibleOperators { .. } => RuleId::FormulaParseError,
ParseError::AssignmentArityMismatch { .. } => RuleId::FormulaParseError,
ParseError::AssignmentInPredicate { .. } => RuleId::AssignmentInPredicate,
_ => RuleId::XmlParseError,
}
}
fn rule_for_build_error(err: &rossi_build::Error) -> Option<RuleId> {
match err {
rossi_build::Error::Parse(p) => Some(rule_for_parse_error(p)),
rossi_build::Error::Project(project) => match project.as_ref() {
ProjectError::Xml(_) | ProjectError::XmlTag(_) | ProjectError::XmlAttribute(_) => {
Some(RuleId::XmlParseError)
}
ProjectError::ReparseFormula { .. } => Some(RuleId::FormulaParseError),
ProjectError::NotADirectory(_) | ProjectError::NoComponents => None,
},
rossi_build::Error::Io(_) | rossi_build::Error::Zip(_) => None,
}
}
fn error_result(
input: Input,
inner: Option<String>,
message: String,
rule_id: Option<RuleId>,
) -> ValidationResult {
ValidationResult {
file: input.path.to_path_buf(),
input: input.kind,
success: false,
inner_filename: inner,
error: Some(message),
component_type: None,
component_name: None,
severity: Some(Severity::Error),
rule_id,
origin: None,
region: None,
proof_summary: None,
}
}
fn parse_error_region(err: &ParseError, source: &str) -> Option<Region> {
let (start_line, start_column) = err.position()?;
let (end_line, end_column) = match err.span() {
Some(span) if span.end > span.start => line_col_1_indexed(source, span.end),
_ => (start_line, start_column),
};
Some(Region {
start_line,
start_column,
end_line,
end_column,
})
}
fn line_col_1_indexed(source: &str, byte_offset: usize) -> (usize, usize) {
let (line, col) = rossi::ast::Span {
start: byte_offset,
end: byte_offset,
}
.to_line_col(source);
(line + 1, col + 1)
}
fn span_to_region(source: &str, span: rossi::ast::Span) -> Region {
let (start_line, start_column) = line_col_1_indexed(source, span.start);
let (end_line, end_column) = line_col_1_indexed(source, span.end);
Region {
start_line,
start_column,
end_line,
end_column,
}
}
fn text_line(result: &ValidationResult) -> (String, bool) {
let mut file_info = result.joined_path(":");
if let Some(region) = &result.region {
file_info = format!("{file_info}:{}:{}", region.start_line, region.start_column);
}
if result.component_name.is_some() {
let line = format!(
"✓ {} - Valid {} '{}'",
file_info,
result.component_type.unwrap_or("?"),
result.component_name.as_deref().unwrap_or("?")
);
return (line, false);
}
if let Some(proofs) = &result.proof_summary {
return (
format!("{file_info} - {}", format_proof_summary(proofs)),
false,
);
}
let (glyph, is_error) = match result.severity {
Some(Severity::Error) => ("✗", true),
Some(Severity::Info) => ("i", false),
Some(Severity::Warning) | None => ("!", false),
};
let prefix = result
.rule_id
.map(|r| format!("[{}] ", r.code()))
.unwrap_or_default();
let where_ = result
.origin
.as_deref()
.map(|o| format!(" ({o})"))
.unwrap_or_default();
let message = result.error.as_deref().unwrap_or("");
(
format!("{glyph} {file_info}{where_} - {prefix}{message}"),
is_error,
)
}
fn format_proof_summary(proofs: &ProofSummaryJson) -> String {
let mut line = format!("Proofs: {}/{} discharged", proofs.discharged, proofs.total);
for (count, label) in [
(proofs.reviewed, "reviewed"),
(proofs.pending, "pending"),
(proofs.unattempted, "unattempted"),
(proofs.broken, "broken"),
] {
if count > 0 {
line.push_str(&format!(", {count} {label}"));
}
}
line
}
fn write_summary(
report: &mut Report,
results: &[ValidationResult],
deny_warnings: bool,
) -> io::Result<()> {
let results: Vec<_> = results
.iter()
.filter(|r| r.proof_summary.is_none())
.collect();
let total = results.len();
let failed = results
.iter()
.filter(|r| row_failed(r, deny_warnings))
.count();
let passed = total - failed;
let rule = "=".repeat(50);
for line in [
format!("\n{rule}"),
"Summary:".to_string(),
format!(" Total: {total}"),
format!(" Passed: {passed} ✓"),
format!(" Failed: {failed} ✗"),
rule.clone(),
] {
report.line(&line, false)?;
}
Ok(())
}
#[derive(Serialize)]
struct JsonValidationResult<'a> {
#[serde(flatten)]
result: &'a ValidationResult,
path: String,
}
fn write_json(results: &[ValidationResult], out: &mut (impl Write + ?Sized)) -> io::Result<()> {
let results: Vec<_> = results
.iter()
.map(|result| JsonValidationResult {
result,
path: result.portable_path(),
})
.collect();
serde_json::to_writer_pretty(&mut *out, &results)
.map_err(|e| io::Error::new(e.io_error_kind().unwrap_or(io::ErrorKind::Other), e))?;
writeln!(out)
}
#[cfg(test)]
mod tests {
use super::*;
struct ByteLimitWriter {
remaining: usize,
}
impl Write for ByteLimitWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.remaining == 0 {
return Err(io::Error::other("write limit reached"));
}
let written = self.remaining.min(buf.len());
self.remaining -= written;
Ok(written)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn json_output_propagates_trailing_newline_failure() {
let serialized = serde_json::to_vec_pretty(&Vec::<ValidationResult>::new()).unwrap();
let mut out = ByteLimitWriter {
remaining: serialized.len(),
};
let error = write_json(&[], &mut out).expect_err("the newline write must fail");
assert_eq!(error.kind(), io::ErrorKind::Other);
}
#[test]
fn portable_path_normalises_backslashes() {
let result = error_result(
Input::directory(Path::new(r"repo\project\")),
Some(r"nested\M.eventb".to_string()),
String::new(),
None,
);
assert_eq!(result.portable_path(), "repo/project/nested/M.eventb");
}
#[test]
fn output_failure_is_reported_and_exits_nonzero() {
let mut stderr = Vec::new();
let exit = finish_structured_output(
Err(io::Error::other("write failed")),
ExitCode::SUCCESS,
&mut stderr,
);
assert_eq!(exit, ExitCode::from(1));
assert_eq!(
String::from_utf8(stderr).unwrap(),
"rossi validate: failed to write output: write failed\n"
);
}
#[test]
fn broken_pipe_preserves_failed_validation_exit() {
let mut stderr = Vec::new();
let failed_validation = ExitCode::from(1);
let exit = finish_structured_output(
Err(io::Error::from(io::ErrorKind::BrokenPipe)),
failed_validation,
&mut stderr,
);
assert_eq!(exit, failed_validation);
assert!(stderr.is_empty());
}
#[test]
fn parse_error_region_covers_reserved_word() {
let source = "CONTEXT c0\nCONSTANTS\n dom\nEND\n";
let err = rossi::parse(source).expect_err("`dom` is reserved");
let region = parse_error_region(&err, source).expect("located error has a region");
assert_eq!((region.start_line, region.start_column), (3, 5));
assert_eq!((region.end_line, region.end_column), (3, 8));
}
#[test]
fn parse_error_region_is_a_point_without_a_span() {
let source = "CONTEXT c\nCONSTANTS\n c1\n +\nEND\n";
let err = rossi::parse(source).expect_err("the stray `+` must fail");
let region = parse_error_region(&err, source).expect("located error has a region");
assert_eq!(
(region.start_line, region.start_column),
(region.end_line, region.end_column)
);
}
#[test]
fn span_to_region_maps_both_ends_one_indexed() {
let source = "line one\nUnion x\n";
let start = source.find("Union").unwrap();
let region = span_to_region(
source,
rossi::ast::Span {
start,
end: start + "Union".len(),
},
);
assert_eq!((region.start_line, region.start_column), (2, 1));
assert_eq!((region.end_line, region.end_column), (2, 6));
}
#[test]
fn loose_lint_diagnostic_is_positioned() {
let source = "CONTEXT C\nSETS\n UNION\nEND\n";
let components = rossi::parse_components(source).unwrap();
let diag = rossi_build::lint::run_component(&components[0])
.into_iter()
.find(|d| d.rule_id == Some(RuleId::ShadowedName))
.expect("UNION shadows the quantified-union token");
let result = fold_diagnostic(Input::file(Path::new("c.eventb")), diag, None, Some(source));
let region = result.region.expect("region resolved from the lint span");
assert_eq!((region.start_line, region.start_column), (3, 5));
assert_eq!((region.end_line, region.end_column), (3, 10));
}
#[test]
fn loose_text_flags_assignment_in_invariant() {
let source = "MACHINE M\nVARIABLES\n x\nINVARIANTS\n @inv1 x := 5\nEND\n";
let cli = ValidateArgs {
files: vec![],
format: OutputFormat::Text,
quiet: false,
continue_on_error: false,
no_semantic: false,
no_lints: false,
output: None,
deny_warnings: false,
show_info: false,
sarif_category: None,
stdin_filename: None,
};
let results = validate_text_source(Input::file(Path::new("m.eventb")), None, source, &cli);
let failures: Vec<_> = results.iter().filter(|r| !r.success).collect();
assert_eq!(failures.len(), 1, "exactly one failure row: {results:?}");
assert_eq!(failures[0].rule_id, Some(RuleId::AssignmentInPredicate));
let region = failures[0].region.expect("EB026 carries a region");
assert_eq!(region.start_line, 5, "operator is on the invariant line");
assert!(
failures[0]
.error
.as_deref()
.is_some_and(|m| m.contains("assignment operator")),
"message names the assignment operator: {:?}",
failures[0].error
);
}
#[test]
fn loose_text_keeps_fallback_alongside_assignment_when_other_errors_exist() {
let source = "MACHINE M\nVARIABLES\n x\n y\nINVARIANTS\n @inv1 y ∈\n @inv2 x := 5\nEND\n";
let cli = ValidateArgs {
files: vec![],
format: OutputFormat::Text,
quiet: false,
continue_on_error: false,
no_semantic: false,
no_lints: false,
output: None,
deny_warnings: false,
show_info: false,
sarif_category: None,
stdin_filename: None,
};
let results = validate_text_source(Input::file(Path::new("m.eventb")), None, source, &cli);
assert!(
results
.iter()
.any(|r| r.rule_id == Some(RuleId::AssignmentInPredicate)),
"the misplaced assignment is reported as EB026: {results:?}"
);
assert!(
results
.iter()
.any(|r| r.rule_id == Some(RuleId::CamilleParseError)),
"the co-occurring parse error is not swallowed (EB004 fallback kept): {results:?}"
);
}
#[test]
fn rule_for_parse_error_maps_assignment_in_predicate() {
let err = ParseError::AssignmentInPredicate {
operator: ":=".to_string(),
line: 1,
column: 3,
span: None,
};
assert_eq!(rule_for_parse_error(&err), RuleId::AssignmentInPredicate);
}
#[test]
fn rule_for_parse_error_maps_assignment_arity_to_eb005() {
let err = ParseError::AssignmentArityMismatch {
targets: 2,
expressions: 1,
line: 1,
column: 6,
span: None,
};
assert_eq!(rule_for_parse_error(&err), RuleId::FormulaParseError);
}
#[test]
fn recovered_assignment_arity_resolves_operator_in_later_component() {
let source = concat!(
"CONTEXT C\nEND\n",
"MACHINE M\nVARIABLES x y\nEVENTS\n",
"EVENT e\nTHEN\n",
"@act1 x, y := 1\n",
"END\nEND\n",
);
let recovered = parse_components_with_recovery(source);
assert_eq!(
recovered.errors.len(),
1,
"unexpected errors: {recovered:?}"
);
let (error, span) = precise_formula_error(&recovered.errors[0])
.expect("recovery retains the precise arity cause");
assert!(matches!(
error,
ParseError::AssignmentArityMismatch {
targets: 2,
expressions: 1,
..
}
));
let span = span.expect("recovered operator span is source-absolute");
assert_eq!(&source[span.start..span.end], ":=");
}
#[test]
fn project_diagnostic_resolves_component_source_and_file() {
let source = "CONTEXT C\nCONSTANTS\n k\nAXIOMS\n @axm1 ⊤\nEND\n";
let components = rossi_build::ProjectComponent::from_eventb("C.eventb", source).unwrap();
let project = rossi_build::Project::new("p", components);
let diag = rossi_build::build(&project)
.diagnostics
.into_iter()
.find(|d| d.message.contains("could not infer type"))
.expect("untyped constant is flagged");
let result =
fold_project_diagnostic(Input::directory(Path::new("proj")), diag, &project, "");
assert_eq!(result.inner_filename.as_deref(), Some("C.eventb"));
let region = result.region.expect("region from the component source");
assert_eq!((region.start_line, region.start_column), (3, 5));
}
#[test]
fn project_diagnostic_inner_filename_is_prefix_qualified() {
let source = "CONTEXT C\nCONSTANTS\n k\nAXIOMS\n @axm1 ⊤\nEND\n";
let components = rossi_build::ProjectComponent::from_eventb("C.buc", source).unwrap();
let project = rossi_build::Project::new("Sub", components);
let diag = rossi_build::build(&project)
.diagnostics
.into_iter()
.find(|d| d.message.contains("could not infer type"))
.expect("untyped constant is flagged");
let result = fold_project_diagnostic(
Input::archive(Path::new("proj.zip")),
diag,
&project,
"Sub/",
);
assert_eq!(result.inner_filename.as_deref(), Some("Sub/C.buc"));
}
}