use std::ops::Range;
use std::path::PathBuf;
use annotate_snippets::{AnnotationKind, Group, Level, Renderer, Snippet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Note,
}
impl Severity {
fn level(self) -> Level<'static> {
match self {
Severity::Error => Level::ERROR,
Severity::Warning => Level::WARNING,
Severity::Note => Level::NOTE,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Location {
pub path: PathBuf,
pub span: Range<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
severity: Severity,
id: &'static str,
message: String,
location: Option<Location>,
label: Option<String>,
notes: Vec<String>,
help: Option<String>,
}
impl Diagnostic {
#[must_use]
pub fn error(id: &'static str, message: impl Into<String>) -> Self {
Self::new(Severity::Error, id, message)
}
#[must_use]
pub fn warning(id: &'static str, message: impl Into<String>) -> Self {
Self::new(Severity::Warning, id, message)
}
#[must_use]
pub fn note(id: &'static str, message: impl Into<String>) -> Self {
Self::new(Severity::Note, id, message)
}
#[must_use]
fn new(severity: Severity, id: &'static str, message: impl Into<String>) -> Self {
Self {
severity,
id,
message: message.into(),
location: None,
label: None,
notes: Vec::new(),
help: None,
}
}
#[must_use]
pub fn at(mut self, path: impl Into<PathBuf>, span: Range<usize>) -> Self {
self.location = Some(Location {
path: path.into(),
span,
});
self
}
#[must_use]
pub fn labelled(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
#[must_use]
pub fn with_help(mut self, help: impl Into<String>) -> Self {
self.help = Some(help.into());
self
}
#[must_use]
pub fn severity(&self) -> Severity {
self.severity
}
#[must_use]
pub fn id(&self) -> &'static str {
self.id
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
pub fn render(&self) -> String {
self.render_with(false)
}
#[must_use]
pub fn render_styled(&self) -> String {
self.render_with(true)
}
#[must_use]
pub fn render_auto(&self) -> String {
if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
self.render_styled()
} else {
self.render()
}
}
fn render_with(&self, styled: bool) -> String {
let renderer = if styled {
Renderer::styled()
} else {
Renderer::plain()
};
let title = self
.severity
.level()
.primary_title(self.message.clone())
.id(self.id);
let mut group = match self.source() {
Some((location, text)) => {
let label = self.label.clone().unwrap_or_else(|| self.message.clone());
title.element(
Snippet::source(text)
.line_start(1)
.path(location.path.display().to_string())
.annotation(
AnnotationKind::Primary
.span(location.span.clone())
.label(label),
),
)
}
None => Group::with_title(title),
};
for note in &self.notes {
group = group.element(Level::NOTE.message(note.clone()));
}
if let Some(help) = &self.help {
group = group.element(Level::HELP.message(help.clone()));
}
renderer.render(&[group]).trim_end().to_string()
}
fn source(&self) -> Option<(&Location, String)> {
let location = self.location.as_ref()?;
let text = std::fs::read_to_string(&location.path).ok()?;
Some((location, text))
}
}
#[must_use]
pub fn render_all(diagnostics: &[Diagnostic]) -> String {
diagnostics
.iter()
.map(Diagnostic::render)
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_title_only_diagnostic_names_level_and_id() {
let rendered = Diagnostic::error("E-tix-cfg", "tix.toml is invalid").render();
assert_eq!(rendered, "error[E-tix-cfg]: tix.toml is invalid");
}
#[test]
fn a_warning_renders_at_the_warning_level() {
let rendered = Diagnostic::warning("W-tix-cfg", "no category map").render();
assert!(rendered.starts_with("warning[W-tix-cfg]:"), "{rendered}");
}
#[test]
fn notes_and_help_are_footers() {
let rendered = Diagnostic::error("E-tix-x", "boom")
.with_note("why it happened")
.with_help("do this instead")
.render();
assert!(rendered.contains("note"), "{rendered}");
assert!(rendered.contains("why it happened"), "{rendered}");
assert!(rendered.contains("do this instead"), "{rendered}");
}
#[test]
fn a_location_renders_a_snippet_with_the_path_and_span() {
let dir = std::env::temp_dir().join("tix-diagnostics-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("x.md");
std::fs::write(&path, "line one\nline two\n").unwrap();
let rendered = Diagnostic::error("E-tix-x", "bad value")
.at(&path, 9..13)
.labelled("this is the value")
.render();
assert!(rendered.contains("bad value"), "{rendered}");
assert!(rendered.contains("x.md"), "{rendered}");
assert!(rendered.contains("line two"), "{rendered}");
assert!(rendered.contains("this is the value"), "{rendered}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_missing_file_degrades_to_a_title() {
let rendered = Diagnostic::error("E-tix-x", "bad value")
.at("/nonexistent/tix/diagnostics/x.md", 0..3)
.render();
assert_eq!(rendered, "error[E-tix-x]: bad value");
}
#[test]
fn render_all_joins_one_block_per_line() {
let rendered = render_all(&[
Diagnostic::error("E-tix-a", "first"),
Diagnostic::error("E-tix-a", "second"),
]);
assert_eq!(rendered, "error[E-tix-a]: first\nerror[E-tix-a]: second");
}
#[test]
fn styled_and_plain_carry_the_same_text() {
let diagnostic = Diagnostic::error("E-tix-x", "boom").with_help("remedy");
assert_eq!(strip_ansi(&diagnostic.render_styled()), diagnostic.render());
}
fn strip_ansi(text: &str) -> String {
let mut out = String::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for e in chars.by_ref() {
if e.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
}