1use std::ops::Range;
34use std::path::PathBuf;
35
36use annotate_snippets::{AnnotationKind, Group, Level, Renderer, Snippet};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Severity {
46 Error,
49 Warning,
51 Note,
53}
54
55impl Severity {
56 fn level(self) -> Level<'static> {
57 match self {
58 Severity::Error => Level::ERROR,
59 Severity::Warning => Level::WARNING,
60 Severity::Note => Level::NOTE,
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Location {
68 pub path: PathBuf,
70 pub span: Range<usize>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Diagnostic {
82 severity: Severity,
83 id: &'static str,
84 message: String,
85 location: Option<Location>,
86 label: Option<String>,
87 notes: Vec<String>,
88 help: Option<String>,
89}
90
91impl Diagnostic {
92 #[must_use]
94 pub fn error(id: &'static str, message: impl Into<String>) -> Self {
95 Self::new(Severity::Error, id, message)
96 }
97
98 #[must_use]
100 pub fn warning(id: &'static str, message: impl Into<String>) -> Self {
101 Self::new(Severity::Warning, id, message)
102 }
103
104 #[must_use]
106 pub fn note(id: &'static str, message: impl Into<String>) -> Self {
107 Self::new(Severity::Note, id, message)
108 }
109
110 #[must_use]
111 fn new(severity: Severity, id: &'static str, message: impl Into<String>) -> Self {
112 Self {
113 severity,
114 id,
115 message: message.into(),
116 location: None,
117 label: None,
118 notes: Vec::new(),
119 help: None,
120 }
121 }
122
123 #[must_use]
129 pub fn at(mut self, path: impl Into<PathBuf>, span: Range<usize>) -> Self {
130 self.location = Some(Location {
131 path: path.into(),
132 span,
133 });
134 self
135 }
136
137 #[must_use]
139 pub fn labelled(mut self, label: impl Into<String>) -> Self {
140 self.label = Some(label.into());
141 self
142 }
143
144 #[must_use]
146 pub fn with_note(mut self, note: impl Into<String>) -> Self {
147 self.notes.push(note.into());
148 self
149 }
150
151 #[must_use]
153 pub fn with_help(mut self, help: impl Into<String>) -> Self {
154 self.help = Some(help.into());
155 self
156 }
157
158 #[must_use]
160 pub fn severity(&self) -> Severity {
161 self.severity
162 }
163
164 #[must_use]
166 pub fn id(&self) -> &'static str {
167 self.id
168 }
169
170 #[must_use]
172 pub fn message(&self) -> &str {
173 &self.message
174 }
175
176 #[must_use]
178 pub fn render(&self) -> String {
179 self.render_with(false)
180 }
181
182 #[must_use]
184 pub fn render_styled(&self) -> String {
185 self.render_with(true)
186 }
187
188 #[must_use]
195 pub fn render_auto(&self) -> String {
196 if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
197 self.render_styled()
198 } else {
199 self.render()
200 }
201 }
202
203 fn render_with(&self, styled: bool) -> String {
204 let renderer = if styled {
205 Renderer::styled()
206 } else {
207 Renderer::plain()
208 };
209 let title = self
210 .severity
211 .level()
212 .primary_title(self.message.clone())
213 .id(self.id);
214 let mut group = match self.source() {
215 Some((location, text)) => {
216 let label = self.label.clone().unwrap_or_else(|| self.message.clone());
217 title.element(
218 Snippet::source(text)
219 .line_start(1)
220 .path(location.path.display().to_string())
221 .annotation(
222 AnnotationKind::Primary
223 .span(location.span.clone())
224 .label(label),
225 ),
226 )
227 }
228 None => Group::with_title(title),
229 };
230 for note in &self.notes {
231 group = group.element(Level::NOTE.message(note.clone()));
232 }
233 if let Some(help) = &self.help {
234 group = group.element(Level::HELP.message(help.clone()));
235 }
236 renderer.render(&[group]).trim_end().to_string()
239 }
240
241 fn source(&self) -> Option<(&Location, String)> {
242 let location = self.location.as_ref()?;
243 let text = std::fs::read_to_string(&location.path).ok()?;
244 Some((location, text))
245 }
246}
247
248#[must_use]
254pub fn render_all(diagnostics: &[Diagnostic]) -> String {
255 diagnostics
256 .iter()
257 .map(Diagnostic::render)
258 .collect::<Vec<_>>()
259 .join("\n")
260}
261
262#[cfg(test)]
263mod tests {
264 #[allow(clippy::wildcard_imports)]
265 use super::*;
266
267 #[test]
268 fn a_title_only_diagnostic_names_level_and_id() {
269 let rendered = Diagnostic::error("E-tix-cfg", "tix.toml is invalid").render();
270 assert_eq!(rendered, "error[E-tix-cfg]: tix.toml is invalid");
271 }
272
273 #[test]
274 fn a_warning_renders_at_the_warning_level() {
275 let rendered = Diagnostic::warning("W-tix-cfg", "no category map").render();
276 assert!(rendered.starts_with("warning[W-tix-cfg]:"), "{rendered}");
277 }
278
279 #[test]
280 fn notes_and_help_are_footers() {
281 let rendered = Diagnostic::error("E-tix-x", "boom")
282 .with_note("why it happened")
283 .with_help("do this instead")
284 .render();
285 assert!(rendered.contains("note"), "{rendered}");
286 assert!(rendered.contains("why it happened"), "{rendered}");
287 assert!(rendered.contains("do this instead"), "{rendered}");
288 }
289
290 #[test]
291 fn a_location_renders_a_snippet_with_the_path_and_span() {
292 let dir = std::env::temp_dir().join("tix-diagnostics-test");
293 std::fs::create_dir_all(&dir).unwrap();
294 let path = dir.join("x.md");
295 std::fs::write(&path, "line one\nline two\n").unwrap();
296 let rendered = Diagnostic::error("E-tix-x", "bad value")
297 .at(&path, 9..13)
298 .labelled("this is the value")
299 .render();
300 assert!(rendered.contains("bad value"), "{rendered}");
301 assert!(rendered.contains("x.md"), "{rendered}");
302 assert!(rendered.contains("line two"), "{rendered}");
303 assert!(rendered.contains("this is the value"), "{rendered}");
304 let _ = std::fs::remove_file(&path);
305 }
306
307 #[test]
308 fn a_missing_file_degrades_to_a_title() {
309 let rendered = Diagnostic::error("E-tix-x", "bad value")
310 .at("/nonexistent/tix/diagnostics/x.md", 0..3)
311 .render();
312 assert_eq!(rendered, "error[E-tix-x]: bad value");
313 }
314
315 #[test]
316 fn render_all_joins_one_block_per_line() {
317 let rendered = render_all(&[
318 Diagnostic::error("E-tix-a", "first"),
319 Diagnostic::error("E-tix-a", "second"),
320 ]);
321 assert_eq!(rendered, "error[E-tix-a]: first\nerror[E-tix-a]: second");
322 }
323
324 #[test]
325 fn styled_and_plain_carry_the_same_text() {
326 let diagnostic = Diagnostic::error("E-tix-x", "boom").with_help("remedy");
327 assert_eq!(strip_ansi(&diagnostic.render_styled()), diagnostic.render());
328 }
329
330 fn strip_ansi(text: &str) -> String {
332 let mut out = String::new();
333 let mut chars = text.chars().peekable();
334 while let Some(c) = chars.next() {
335 if c == '\u{1b}' {
336 for e in chars.by_ref() {
337 if e.is_ascii_alphabetic() {
338 break;
339 }
340 }
341 } else {
342 out.push(c);
343 }
344 }
345 out
346 }
347}