Skip to main content

mf2_build/
report.rs

1//! What a build says about a corpus: diagnostics with a file, a line and a
2//! column, in one place so that `build.rs`, `mf2 check` and `--format json`
3//! all say the same thing.
4
5use std::fmt::Write as _;
6use std::path::{Path, PathBuf};
7
8use serde::Serialize;
9
10use mf2_model::ErrorKind;
11
12use crate::lint::{Level, Lint};
13
14/// Serializes an error kind as the name the WG suite gives it.
15#[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
16fn suite_name<S: serde::Serializer>(kind: &Option<ErrorKind>, s: S) -> Result<S::Ok, S::Error> {
17    match kind {
18        Some(k) => s.serialize_str(k.suite_name()),
19        None => s.serialize_none(),
20    }
21}
22
23/// One thing a build has to say.
24#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
25#[non_exhaustive]
26pub struct Diagnostic {
27    /// Whether it fails the build.
28    pub level: Level,
29    /// The locale it was found in.
30    pub locale: String,
31    /// The file.
32    pub file: PathBuf,
33    /// One-based line.
34    pub line: u32,
35    /// One-based column, in characters.
36    pub column: u32,
37    /// The message's id, where there is one.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub id: Option<String>,
40    /// Which lint; `None` for a syntax or data-model error, which is not a
41    /// lint but the message being wrong.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub lint: Option<Lint>,
44    /// The MF2 error kind, for a diagnostic that *is* one — a syntax or
45    /// data-model error. It is what the WG suite names the error, so a
46    /// consumer can check the build's verdict without reading prose:
47    /// conformance layer L5 does, and `--format json` carries it.
48    #[serde(
49        rename = "kind",
50        serialize_with = "suite_name",
51        skip_serializing_if = "Option::is_none"
52    )]
53    pub kind: Option<ErrorKind>,
54    /// What is wrong.
55    pub message: String,
56}
57
58impl Diagnostic {
59    /// The one-line form: `file:line:column: level: message [lint]`.
60    pub fn write_text(&self, out: &mut String) {
61        let _ = write!(
62            out,
63            "{}:{}:{}: {}: {}",
64            self.file.display(),
65            self.line,
66            self.column,
67            self.level,
68            self.message
69        );
70        if let Some(id) = &self.id {
71            let _ = write!(out, " (in {id}, locale {})", self.locale);
72        } else {
73            let _ = write!(out, " (locale {})", self.locale);
74        }
75        if let Some(lint) = self.lint {
76            let _ = write!(out, " [{lint}]");
77        }
78        out.push('\n');
79    }
80}
81
82/// Everything a build found, in the order found.
83#[derive(Clone, Debug, Default, Serialize)]
84#[non_exhaustive]
85pub struct Report {
86    /// The diagnostics.
87    pub diagnostics: Vec<Diagnostic>,
88}
89
90impl Report {
91    /// An empty report.
92    pub fn new() -> Report {
93        Report::default()
94    }
95
96    /// Adds a diagnostic, unless its level is [`Level::Allow`].
97    pub fn push(&mut self, diagnostic: Diagnostic) {
98        if diagnostic.level != Level::Allow {
99            self.diagnostics.push(diagnostic);
100        }
101    }
102
103    /// Adds everything of `other`.
104    pub fn extend(&mut self, other: Report) {
105        self.diagnostics.extend(other.diagnostics);
106    }
107
108    /// How many diagnostics fail the build.
109    pub fn errors(&self) -> usize {
110        self.count(Level::Error)
111    }
112
113    /// How many are warnings.
114    pub fn warnings(&self) -> usize {
115        self.count(Level::Warn)
116    }
117
118    fn count(&self, level: Level) -> usize {
119        self.diagnostics.iter().filter(|d| d.level == level).count()
120    }
121
122    /// Whether the build may go on.
123    pub fn is_clean(&self) -> bool {
124        self.errors() == 0
125    }
126
127    /// Nothing at all to say.
128    pub fn is_empty(&self) -> bool {
129        self.diagnostics.is_empty()
130    }
131
132    /// The locales that have an error.
133    pub fn failing_locales(&self) -> std::collections::BTreeSet<&str> {
134        self.diagnostics
135            .iter()
136            .filter(|d| d.level == Level::Error)
137            .map(|d| d.locale.as_str())
138            .collect()
139    }
140
141    /// One line per diagnostic, sorted by file and position so that two runs
142    /// of a build print the same thing.
143    pub fn to_text(&self) -> String {
144        let mut sorted: Vec<&Diagnostic> = self.diagnostics.iter().collect();
145        sorted.sort_by(|a, b| {
146            (&a.file, a.line, a.column, &a.message).cmp(&(&b.file, b.line, b.column, &b.message))
147        });
148        let mut out = String::new();
149        for d in sorted {
150            d.write_text(&mut out);
151        }
152        out
153    }
154
155    /// `--format json`: one object per diagnostic under `"diagnostics"`.
156    pub fn to_json(&self) -> String {
157        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_owned())
158    }
159
160    /// The cargo instructions a `build.rs` prints for its report:
161    /// `cargo::error=` for an error, `cargo::warning=` for the rest.
162    pub fn to_cargo_lines(&self) -> String {
163        let mut out = String::new();
164        for d in &self.diagnostics {
165            let mut line = String::new();
166            d.write_text(&mut line);
167            let kind = if d.level == Level::Error {
168                "error"
169            } else {
170                "warning"
171            };
172            for line in line.trim_end().lines() {
173                let _ = writeln!(out, "cargo::{kind}={line}");
174            }
175        }
176        out
177    }
178}
179
180/// Builds diagnostics for one locale, so that the caller does not repeat the
181/// tag and the file on every call.
182pub(crate) struct Sink<'r> {
183    report: &'r mut Report,
184    locale: String,
185}
186
187impl<'r> Sink<'r> {
188    /// A sink that tags everything with `locale`.
189    pub(crate) fn new(report: &'r mut Report, locale: impl Into<String>) -> Sink<'r> {
190        Sink {
191            report,
192            locale: locale.into(),
193        }
194    }
195
196    /// Adds one diagnostic.
197    pub(crate) fn add(
198        &mut self,
199        level: Level,
200        lint: Option<Lint>,
201        file: &Path,
202        at: mf2_resource::Position,
203        id: Option<&str>,
204        message: impl Into<String>,
205    ) {
206        self.report.push(Diagnostic {
207            level,
208            locale: self.locale.clone(),
209            file: file.to_path_buf(),
210            line: at.line,
211            column: at.column,
212            id: id.map(ToOwned::to_owned),
213            lint,
214            kind: None,
215            message: message.into(),
216        });
217    }
218
219    /// A message the spec refuses: a syntax or data-model error, with the
220    /// kind the WG suite names it by.
221    pub(crate) fn add_invalid(
222        &mut self,
223        file: &Path,
224        at: mf2_resource::Position,
225        id: Option<&str>,
226        kind: ErrorKind,
227        message: impl Into<String>,
228    ) {
229        self.report.push(Diagnostic {
230            level: Level::Error,
231            locale: self.locale.clone(),
232            file: file.to_path_buf(),
233            line: at.line,
234            column: at.column,
235            id: id.map(ToOwned::to_owned),
236            lint: None,
237            kind: Some(kind),
238            message: message.into(),
239        });
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::{Diagnostic, Report};
246    use crate::lint::{Level, Lint};
247    use std::path::PathBuf;
248
249    fn diagnostic(level: Level, line: u32, message: &str) -> Diagnostic {
250        Diagnostic {
251            kind: None,
252            level,
253            locale: "pl".to_owned(),
254            file: PathBuf::from("locales/pl/chat.mf2"),
255            line,
256            column: 3,
257            id: Some("chat.row".to_owned()),
258            lint: Some(Lint::MissingTranslation),
259            message: message.to_owned(),
260        }
261    }
262
263    #[test]
264    fn a_report_counts_and_sorts() {
265        let mut report = Report::new();
266        report.push(diagnostic(Level::Warn, 9, "later"));
267        report.push(diagnostic(Level::Error, 2, "earlier"));
268        // An allowed lint is not a diagnostic at all.
269        report.push(diagnostic(Level::Allow, 1, "quiet"));
270        assert_eq!(report.errors(), 1);
271        assert_eq!(report.warnings(), 1);
272        assert!(!report.is_clean());
273        assert_eq!(report.failing_locales(), ["pl"].into_iter().collect());
274        let text = report.to_text();
275        assert!(
276            text.starts_with("locales/pl/chat.mf2:2:3: error: earlier (in chat.row, locale pl) [missing-translation]\n"),
277            "{text}"
278        );
279        assert!(!text.contains("quiet"), "{text}");
280    }
281
282    #[test]
283    fn json_carries_the_fields_ci_needs() {
284        let mut report = Report::new();
285        report.push(diagnostic(Level::Error, 2, "broken"));
286        let json: serde_json::Value = serde_json::from_str(&report.to_json()).expect("json");
287        let first = &json["diagnostics"][0];
288        assert_eq!(first["level"], "error");
289        assert_eq!(first["lint"], "missing-translation");
290        assert_eq!(first["file"], "locales/pl/chat.mf2");
291        assert_eq!(first["line"], 2);
292        assert_eq!(first["column"], 3);
293        assert_eq!(first["id"], "chat.row");
294        assert_eq!(first["locale"], "pl");
295    }
296}