use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, LazyLock};
use ariadne::{Color, FnCache, Label, Report, ReportKind};
use camino::Utf8PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::AsRefStr)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Severity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SourceSpan {
pub file: Utf8PathBuf,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LabelSpan {
pub span: SourceSpan,
pub message: Option<String>,
pub primary: bool,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Diagnostic {
pub code: String,
pub severity: Severity,
pub message: String,
#[cfg_attr(feature = "serde", serde(default))]
pub labels: Vec<LabelSpan>,
pub advice: Option<String>,
}
impl Diagnostic {
#[must_use]
pub fn error(code: &str, message: impl Into<String>) -> Self {
Self::new(code, message, Severity::Error)
}
#[must_use]
pub fn warning(code: &str, message: impl Into<String>) -> Self {
Self::new(code, message, Severity::Warning)
}
#[must_use]
pub fn info(code: &str, message: impl Into<String>) -> Self {
Self::new(code, message, Severity::Info)
}
fn new(code: &str, message: impl Into<String>, severity: Severity) -> Self {
Self {
code: code.to_string(),
severity,
message: message.into(),
labels: Vec::new(),
advice: None,
}
}
#[must_use]
pub fn with_source(mut self, span: SourceSpan) -> Self {
self.labels.push(LabelSpan {
span,
message: None,
primary: true,
});
self
}
#[must_use]
pub fn with_label(mut self, span: SourceSpan, message: impl Into<String>) -> Self {
self.labels.push(LabelSpan {
span,
message: Some(message.into()),
primary: false,
});
self
}
#[must_use]
pub fn with_label_primary(mut self, span: SourceSpan, message: impl Into<String>) -> Self {
self.labels.push(LabelSpan {
span,
message: Some(message.into()),
primary: true,
});
self
}
#[must_use]
pub fn with_advice(mut self, advice: impl Into<String>) -> Self {
self.advice = Some(advice.into());
self
}
}
impl fmt::Display for Diagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}", self.code, self.message)
}
}
impl std::error::Error for Diagnostic {}
#[diagnostic::on_unimplemented(
message = "implement `ToDiagnostic` to render this type as a `Diagnostic`",
note = "anyhow/eyre/error-stack boundaries provide conversions via the `compat-*` features"
)]
pub trait ToDiagnostic {
fn to_diagnostic(&self) -> Diagnostic;
}
impl ToDiagnostic for Diagnostic {
fn to_diagnostic(&self) -> Diagnostic {
self.clone()
}
}
impl<E: Send + Sync + 'static> ToDiagnostic for crate::exn::Fault<E> {
fn to_diagnostic(&self) -> Diagnostic {
let frame = self.frame();
let provided = core::error::request_value::<crate::errors::ErrorCode>(frame.error());
let code = provided.map_or_else(|| uncoded_code(self, frame), |code| code.0.to_string());
let advice = provided
.and_then(|code| crate::errors::lookup_error(code.0))
.and_then(|entry| entry.advice.map(str::to_string))
.or_else(|| {
let context = frame.context();
(!matches!(context, crate::exn::Context::None)).then(|| context.to_string())
});
let mut diag = Diagnostic::error(&code, frame.error().to_string());
diag.advice = advice;
diag
}
}
fn uncoded_code<E: Send + Sync + 'static>(
fault: &crate::exn::Fault<E>,
frame: &crate::exn::Frame,
) -> String {
let type_name = frame.type_name();
if !type_name.starts_with("dyn ") {
return type_name
.rsplit("::")
.next()
.unwrap_or(type_name)
.to_string();
}
let typed: &E = fault;
if let Some(boxed) = (typed as &dyn std::any::Any).downcast_ref::<crate::BoxError>() {
let real: &(dyn std::error::Error + Send + Sync + 'static) = &**boxed;
let debug = format!("{real:?}");
let ident: String = debug
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !ident.is_empty() {
return ident;
}
}
"Error".to_string()
}
#[must_use]
pub fn render_any(x: &impl ToDiagnostic) -> String {
render_diagnostic(&x.to_diagnostic())
}
pub fn eprint_any(x: &impl ToDiagnostic) {
eprint_diagnostic(&x.to_diagnostic());
}
static SOURCES: LazyLock<parking_lot::RwLock<HashMap<String, Arc<str>>>> =
LazyLock::new(|| parking_lot::RwLock::new(HashMap::new()));
pub fn register_source(name: impl Into<String>, contents: impl Into<String>) {
SOURCES.write().insert(name.into(), contents.into().into());
}
pub(crate) fn registered_source(name: &str) -> Option<String> {
SOURCES.read().get(name).map(ToString::to_string)
}
fn should_color(is_tty: bool, no_color_env: Option<&str>, term_env: Option<&str>) -> bool {
match crate::config::color_mode() {
crate::config::ColorMode::Always => true,
crate::config::ColorMode::Never => false,
crate::config::ColorMode::Auto => {
is_tty && no_color_env.is_none() && term_env != Some("dumb")
}
}
}
fn build_report(
diag: &Diagnostic,
color: bool,
) -> Report<'static, (String, std::ops::Range<usize>)> {
let (kind, label_color) = match diag.severity {
Severity::Error => (ReportKind::Error, Color::Red),
Severity::Warning => (ReportKind::Warning, Color::Yellow),
Severity::Info => (ReportKind::Advice, Color::Cyan),
};
let clamped = |start: usize, end: usize| start.min(end)..start.max(end);
let anchor = diag
.labels
.iter()
.find(|l| l.primary)
.or_else(|| diag.labels.first());
let (file, range) = anchor.map_or_else(
|| ("<unknown>".to_string(), 0..0),
|l| (l.span.file.to_string(), clamped(l.span.start, l.span.end)),
);
let mut builder = Report::build(kind, (file.clone(), range.clone()))
.with_config(ariadne::Config::default().with_color(color))
.with_message(format!("[{}] {}", diag.code, diag.message));
if diag.labels.is_empty() {
builder = builder.with_label(
Label::new((file, range))
.with_message(diag.severity.as_ref())
.with_color(label_color),
);
} else {
for label in &diag.labels {
let message = label
.message
.clone()
.unwrap_or_else(|| diag.severity.as_ref().to_string());
builder = builder.with_label(
Label::new((
label.span.file.to_string(),
clamped(label.span.start, label.span.end),
))
.with_message(message)
.with_color(if label.primary {
label_color
} else {
Color::Fixed(244)
}),
);
}
}
if let Some(entry) = crate::errors::lookup_error(&diag.code) {
builder = builder.with_note(format!(
"{} [{}] — {} (category: {})",
entry.name, entry.code, entry.display, entry.category
));
}
if let Some(advice) = &diag.advice {
builder = builder.with_note(advice);
}
builder.finish()
}
#[must_use]
pub fn render_diagnostic(diag: &Diagnostic) -> String {
let mut buf = Vec::new();
if let Err(e) = build_report(diag, false).write(source_cache(), &mut buf) {
log::error!(target: crate::log_targets::DIAGNOSTIC, "failed to render diagnostic: {e}");
return format!("[{}] {}", diag.code, diag.message);
}
String::from_utf8_lossy(&buf).into_owned()
}
pub fn eprint_diagnostic(diag: &Diagnostic) {
let color = should_color(
std::io::IsTerminal::is_terminal(&std::io::stderr()),
std::env::var_os("NO_COLOR").is_some().then_some("1"),
std::env::var("TERM").ok().as_deref(),
);
if let Err(e) = build_report(diag, color).eprint(source_cache()) {
log::error!(target: crate::log_targets::DIAGNOSTIC, "failed to render diagnostic: {e}");
}
}
fn source_cache() -> FnCache<String, impl FnMut(&String) -> Result<String, String>, String> {
FnCache::new(|id: &String| {
if let Some(src) = SOURCES.read().get(id) {
return Ok(src.to_string());
}
fs_err::read_to_string(id).map_err(|e| e.to_string())
})
}
#[cfg(test)]
mod tests {
use super::should_color;
#[test]
fn should_color_tty_no_no_color_with_term() {
assert!(should_color(true, None, Some("xterm")));
}
#[test]
fn should_color_no_color_disables_even_empty() {
assert!(!should_color(true, Some("1"), Some("xterm")));
assert!(!should_color(true, Some(""), Some("xterm")));
}
#[test]
fn should_color_term_dumb_disables() {
assert!(!should_color(true, None, Some("dumb")));
}
#[test]
fn should_color_non_tty_disables() {
assert!(!should_color(false, None, Some("xterm")));
}
}