use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use serde::Serialize;
use mf2_model::ErrorKind;
use crate::lint::{Level, Lint};
#[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
fn suite_name<S: serde::Serializer>(kind: &Option<ErrorKind>, s: S) -> Result<S::Ok, S::Error> {
match kind {
Some(k) => s.serialize_str(k.suite_name()),
None => s.serialize_none(),
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Diagnostic {
pub level: Level,
pub locale: String,
pub file: PathBuf,
pub line: u32,
pub column: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lint: Option<Lint>,
#[serde(
rename = "kind",
serialize_with = "suite_name",
skip_serializing_if = "Option::is_none"
)]
pub kind: Option<ErrorKind>,
pub message: String,
}
impl Diagnostic {
pub fn write_text(&self, out: &mut String) {
let _ = write!(
out,
"{}:{}:{}: {}: {}",
self.file.display(),
self.line,
self.column,
self.level,
self.message
);
if let Some(id) = &self.id {
let _ = write!(out, " (in {id}, locale {})", self.locale);
} else {
let _ = write!(out, " (locale {})", self.locale);
}
if let Some(lint) = self.lint {
let _ = write!(out, " [{lint}]");
}
out.push('\n');
}
}
#[derive(Clone, Debug, Default, Serialize)]
#[non_exhaustive]
pub struct Report {
pub diagnostics: Vec<Diagnostic>,
}
impl Report {
pub fn new() -> Report {
Report::default()
}
pub fn push(&mut self, diagnostic: Diagnostic) {
if diagnostic.level != Level::Allow {
self.diagnostics.push(diagnostic);
}
}
pub fn extend(&mut self, other: Report) {
self.diagnostics.extend(other.diagnostics);
}
pub fn errors(&self) -> usize {
self.count(Level::Error)
}
pub fn warnings(&self) -> usize {
self.count(Level::Warn)
}
fn count(&self, level: Level) -> usize {
self.diagnostics.iter().filter(|d| d.level == level).count()
}
pub fn is_clean(&self) -> bool {
self.errors() == 0
}
pub fn is_empty(&self) -> bool {
self.diagnostics.is_empty()
}
pub fn failing_locales(&self) -> std::collections::BTreeSet<&str> {
self.diagnostics
.iter()
.filter(|d| d.level == Level::Error)
.map(|d| d.locale.as_str())
.collect()
}
pub fn to_text(&self) -> String {
let mut sorted: Vec<&Diagnostic> = self.diagnostics.iter().collect();
sorted.sort_by(|a, b| {
(&a.file, a.line, a.column, &a.message).cmp(&(&b.file, b.line, b.column, &b.message))
});
let mut out = String::new();
for d in sorted {
d.write_text(&mut out);
}
out
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_owned())
}
pub fn to_cargo_lines(&self) -> String {
let mut out = String::new();
for d in &self.diagnostics {
let mut line = String::new();
d.write_text(&mut line);
let kind = if d.level == Level::Error {
"error"
} else {
"warning"
};
for line in line.trim_end().lines() {
let _ = writeln!(out, "cargo::{kind}={line}");
}
}
out
}
}
pub(crate) struct Sink<'r> {
report: &'r mut Report,
locale: String,
}
impl<'r> Sink<'r> {
pub(crate) fn new(report: &'r mut Report, locale: impl Into<String>) -> Sink<'r> {
Sink {
report,
locale: locale.into(),
}
}
pub(crate) fn add(
&mut self,
level: Level,
lint: Option<Lint>,
file: &Path,
at: mf2_resource::Position,
id: Option<&str>,
message: impl Into<String>,
) {
self.report.push(Diagnostic {
level,
locale: self.locale.clone(),
file: file.to_path_buf(),
line: at.line,
column: at.column,
id: id.map(ToOwned::to_owned),
lint,
kind: None,
message: message.into(),
});
}
pub(crate) fn add_invalid(
&mut self,
file: &Path,
at: mf2_resource::Position,
id: Option<&str>,
kind: ErrorKind,
message: impl Into<String>,
) {
self.report.push(Diagnostic {
level: Level::Error,
locale: self.locale.clone(),
file: file.to_path_buf(),
line: at.line,
column: at.column,
id: id.map(ToOwned::to_owned),
lint: None,
kind: Some(kind),
message: message.into(),
});
}
}
#[cfg(test)]
mod tests {
use super::{Diagnostic, Report};
use crate::lint::{Level, Lint};
use std::path::PathBuf;
fn diagnostic(level: Level, line: u32, message: &str) -> Diagnostic {
Diagnostic {
kind: None,
level,
locale: "pl".to_owned(),
file: PathBuf::from("locales/pl/chat.mf2"),
line,
column: 3,
id: Some("chat.row".to_owned()),
lint: Some(Lint::MissingTranslation),
message: message.to_owned(),
}
}
#[test]
fn a_report_counts_and_sorts() {
let mut report = Report::new();
report.push(diagnostic(Level::Warn, 9, "later"));
report.push(diagnostic(Level::Error, 2, "earlier"));
report.push(diagnostic(Level::Allow, 1, "quiet"));
assert_eq!(report.errors(), 1);
assert_eq!(report.warnings(), 1);
assert!(!report.is_clean());
assert_eq!(report.failing_locales(), ["pl"].into_iter().collect());
let text = report.to_text();
assert!(
text.starts_with("locales/pl/chat.mf2:2:3: error: earlier (in chat.row, locale pl) [missing-translation]\n"),
"{text}"
);
assert!(!text.contains("quiet"), "{text}");
}
#[test]
fn json_carries_the_fields_ci_needs() {
let mut report = Report::new();
report.push(diagnostic(Level::Error, 2, "broken"));
let json: serde_json::Value = serde_json::from_str(&report.to_json()).expect("json");
let first = &json["diagnostics"][0];
assert_eq!(first["level"], "error");
assert_eq!(first["lint"], "missing-translation");
assert_eq!(first["file"], "locales/pl/chat.mf2");
assert_eq!(first["line"], 2);
assert_eq!(first["column"], 3);
assert_eq!(first["id"], "chat.row");
assert_eq!(first["locale"], "pl");
}
}