use super::{Diagnostic, Severity};
use crate::ast::SourceMap;
pub struct AnsiRenderer {
pub use_color: bool,
}
impl AnsiRenderer {
fn bold(&self, s: &str) -> String {
if self.use_color {
format!("\x1b[1m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn bold_red(&self, s: &str) -> String {
if self.use_color {
format!("\x1b[1;31m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn cyan(&self, s: &str) -> String {
if self.use_color {
format!("\x1b[36m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn dim(&self, s: &str) -> String {
if self.use_color {
format!("\x1b[2m{s}\x1b[0m")
} else {
s.to_string()
}
}
pub fn render(&self, d: &Diagnostic) -> String {
let mut out = String::new();
let severity_label = match d.severity {
Severity::Error => self.bold_red("error"),
Severity::Warning => self.bold(&self.cyan("warning")),
};
let code_part = match d.code {
Some(code) => self.bold(&format!("[{code}]")),
None => String::new(),
};
out.push_str(&format!(
"{}{}: {}\n",
severity_label,
code_part,
self.bold(&d.message)
));
let primary = d.labels.iter().find(|l| l.is_primary);
if let (Some(label), Some(source)) = (primary, &d.source) {
let map = SourceMap::new(source);
let (line, col) = map.lookup(label.span.start);
let line_text = map.line_text(source, line);
out.push_str(&format!(" {} {}:{}\n", self.cyan("-->"), line, col));
let gutter = line.to_string().len();
let pipe = self.cyan("|");
let pad = " ".repeat(gutter);
out.push_str(&format!("{pad} {pipe}\n"));
let line_num = self.cyan(&format!("{line:>gutter$}"));
out.push_str(&format!("{line_num} {pipe} {line_text}\n"));
let span_start_in_line = col.saturating_sub(1);
let span_len = (label.span.end.saturating_sub(label.span.start)).max(1);
let carets = self.bold_red(&"^".repeat(span_len));
let indent = " ".repeat(span_start_in_line);
if label.message.is_empty() {
out.push_str(&format!("{pad} {pipe} {indent}{carets}\n"));
} else {
out.push_str(&format!(
"{pad} {pipe} {indent}{carets} {}\n",
self.bold_red(&label.message)
));
}
out.push_str(&format!("{pad} {pipe}\n"));
}
for label in d.labels.iter().filter(|l| !l.is_primary) {
if !label.message.is_empty() {
out.push_str(&format!(" {} {}\n", self.dim("="), label.message));
}
}
for note in &d.notes {
out.push_str(&format!(" {} note: {}\n", self.dim("="), note));
}
if let Some(suggestion) = &d.suggestion {
out.push_str(&format!(" {} suggestion: {}\n", self.dim("="), suggestion));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Span;
fn make_diag(source: &str, start: usize, end: usize) -> Diagnostic {
Diagnostic::error("type mismatch")
.with_span(Span { start, end }, "here")
.with_source(source.to_string())
.with_note("in function 'f'")
.with_suggestion("use n instead of t")
}
#[test]
fn render_contains_error_label() {
let r = AnsiRenderer { use_color: false };
let d = make_diag("f x:n>n;x", 2, 3);
let out = r.render(&d);
assert!(out.contains("error:"), "missing 'error:' in:\n{out}");
assert!(out.contains("type mismatch"), "missing message in:\n{out}");
}
#[test]
fn render_contains_location() {
let r = AnsiRenderer { use_color: false };
let d = make_diag("f x:n>n;x", 2, 3);
let out = r.render(&d);
assert!(out.contains("-->"), "missing '-->' in:\n{out}");
assert!(out.contains("1:"), "missing line number in:\n{out}");
}
#[test]
fn render_contains_source_line() {
let r = AnsiRenderer { use_color: false };
let d = make_diag("f x:n>n;x", 2, 3);
let out = r.render(&d);
assert!(out.contains("f x:n>n;x"), "missing source line in:\n{out}");
}
#[test]
fn render_contains_carets() {
let r = AnsiRenderer { use_color: false };
let d = make_diag("f x:n>n;x", 2, 3);
let out = r.render(&d);
assert!(out.contains('^'), "missing carets in:\n{out}");
}
#[test]
fn render_contains_note_and_suggestion() {
let r = AnsiRenderer { use_color: false };
let d = make_diag("f x:n>n;x", 2, 3);
let out = r.render(&d);
assert!(out.contains("note:"), "missing note in:\n{out}");
assert!(out.contains("suggestion:"), "missing suggestion in:\n{out}");
assert!(
out.contains("in function 'f'"),
"missing note text in:\n{out}"
);
}
#[test]
fn render_no_source_still_works() {
let r = AnsiRenderer { use_color: false };
let d = Diagnostic::error("something bad");
let out = r.render(&d);
assert!(out.contains("error: something bad"));
assert!(!out.contains("-->"));
}
#[test]
fn render_with_color_contains_ansi_codes() {
let r = AnsiRenderer { use_color: true };
let d = make_diag("f x:n>n;x", 2, 3);
let out = r.render(&d);
assert!(
out.contains("\x1b["),
"expected ANSI codes when use_color=true"
);
}
#[test]
fn render_without_color_no_ansi_codes() {
let r = AnsiRenderer { use_color: false };
let d = make_diag("f x:n>n;x", 2, 3);
let out = r.render(&d);
assert!(
!out.contains("\x1b["),
"unexpected ANSI codes when use_color=false"
);
}
#[test]
fn render_multiline_source_correct_line() {
let source = "f x:n>n;x\ng y:t>t;y";
let r = AnsiRenderer { use_color: false };
let d = Diagnostic::error("bad")
.with_span(Span { start: 10, end: 11 }, "here")
.with_source(source.to_string());
let out = r.render(&d);
assert!(out.contains("2:"), "expected line 2 in:\n{out}");
assert!(out.contains("g y:t>t;y"), "expected second line in:\n{out}");
}
#[test]
fn caret_length_matches_span() {
let r = AnsiRenderer { use_color: false };
let d = Diagnostic::error("bad")
.with_span(Span { start: 2, end: 5 }, "")
.with_source("f x:n>n;x".to_string());
let out = r.render(&d);
assert!(out.contains("^^^"), "expected 3 carets in:\n{out}");
}
#[test]
fn render_warning_severity_label() {
let r = AnsiRenderer { use_color: false };
let mut d = Diagnostic::error("unused variable");
d.severity = Severity::Warning;
let out = r.render(&d);
assert!(out.contains("warning"), "missing 'warning' in:\n{out}");
assert!(!out.contains("error:"), "should not say error in:\n{out}");
}
#[test]
fn render_warning_with_color() {
let r = AnsiRenderer { use_color: true };
let mut d = Diagnostic::error("unused variable");
d.severity = Severity::Warning;
let out = r.render(&d);
assert!(out.contains("warning"), "missing 'warning' in:\n{out}");
assert!(
out.contains("\x1b["),
"expected ANSI codes for warning with color"
);
}
#[test]
fn render_secondary_label_with_nonempty_message() {
let r = AnsiRenderer { use_color: false };
let d = Diagnostic::error("type mismatch")
.with_secondary_span(Span { start: 5, end: 8 }, "defined here");
let out = r.render(&d);
assert!(
out.contains("defined here"),
"missing secondary label message in:\n{out}"
);
}
#[test]
fn render_secondary_label_empty_message_skipped() {
let r = AnsiRenderer { use_color: false };
let d =
Diagnostic::error("type mismatch").with_secondary_span(Span { start: 5, end: 8 }, "");
let out = r.render(&d);
assert!(out.contains("error:"), "missing error header in:\n{out}");
}
#[test]
fn render_primary_label_with_nonempty_message() {
let r = AnsiRenderer { use_color: false };
let d = Diagnostic::error("bad")
.with_span(Span { start: 2, end: 5 }, "this is the problem")
.with_source("f x:n>n;x".to_string());
let out = r.render(&d);
assert!(
out.contains("this is the problem"),
"missing label message in:\n{out}"
);
}
}