1use 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#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
25#[non_exhaustive]
26pub struct Diagnostic {
27 pub level: Level,
29 pub locale: String,
31 pub file: PathBuf,
33 pub line: u32,
35 pub column: u32,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub id: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
43 pub lint: Option<Lint>,
44 #[serde(
49 rename = "kind",
50 serialize_with = "suite_name",
51 skip_serializing_if = "Option::is_none"
52 )]
53 pub kind: Option<ErrorKind>,
54 pub message: String,
56}
57
58impl Diagnostic {
59 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#[derive(Clone, Debug, Default, Serialize)]
84#[non_exhaustive]
85pub struct Report {
86 pub diagnostics: Vec<Diagnostic>,
88}
89
90impl Report {
91 pub fn new() -> Report {
93 Report::default()
94 }
95
96 pub fn push(&mut self, diagnostic: Diagnostic) {
98 if diagnostic.level != Level::Allow {
99 self.diagnostics.push(diagnostic);
100 }
101 }
102
103 pub fn extend(&mut self, other: Report) {
105 self.diagnostics.extend(other.diagnostics);
106 }
107
108 pub fn errors(&self) -> usize {
110 self.count(Level::Error)
111 }
112
113 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 pub fn is_clean(&self) -> bool {
124 self.errors() == 0
125 }
126
127 pub fn is_empty(&self) -> bool {
129 self.diagnostics.is_empty()
130 }
131
132 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 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 pub fn to_json(&self) -> String {
157 serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_owned())
158 }
159
160 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
180pub(crate) struct Sink<'r> {
183 report: &'r mut Report,
184 locale: String,
185}
186
187impl<'r> Sink<'r> {
188 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 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 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 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}