use alloc::string::String;
use alloc::vec::Vec;
use core::fmt;
use source_lang::{SourceId, SourceMap};
use span_lang::BytePos;
use crate::{Diagnostic, Label};
const TAB_WIDTH: usize = 4;
struct Palette {
header: &'static str,
gutter: &'static str,
primary: &'static str,
secondary: &'static str,
note: &'static str,
help: &'static str,
reset: &'static str,
}
impl Palette {
const PLAIN: Palette = Palette {
header: "",
gutter: "",
primary: "",
secondary: "",
note: "",
help: "",
reset: "",
};
#[cfg(feature = "color")]
fn coloured(severity: crate::Severity) -> Palette {
use crate::Severity;
let sev = match severity {
Severity::Error => "\x1b[1;31m", Severity::Warning => "\x1b[1;33m", Severity::Note => "\x1b[1;32m", Severity::Help => "\x1b[1;36m", };
Palette {
header: sev,
gutter: "\x1b[1;34m", primary: sev,
secondary: "\x1b[1;34m", note: "\x1b[1m", help: "\x1b[1;36m", reset: "\x1b[0m",
}
}
#[inline]
const fn marker(&self, primary: bool) -> &'static str {
if primary {
self.primary
} else {
self.secondary
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Default)]
pub struct Renderer {
#[cfg(feature = "color")]
color: bool,
}
impl Renderer {
#[inline]
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[cfg(feature = "color")]
#[cfg_attr(docsrs, doc(cfg(feature = "color")))]
#[inline]
#[must_use]
pub fn with_color(mut self) -> Self {
self.color = true;
self
}
#[cfg(feature = "color")]
fn palette(&self, severity: crate::Severity) -> Palette {
if self.color {
Palette::coloured(severity)
} else {
Palette::PLAIN
}
}
#[cfg(not(feature = "color"))]
fn palette(&self, _severity: crate::Severity) -> Palette {
Palette::PLAIN
}
#[must_use]
pub fn render(&self, diag: &Diagnostic, map: &SourceMap) -> String {
let mut out = String::new();
let _ = self.render_to(&mut out, diag, map);
out
}
pub fn render_to(
&self,
out: &mut impl fmt::Write,
diag: &Diagnostic,
map: &SourceMap,
) -> fmt::Result {
let palette = self.palette(diag.severity());
writeln!(
out,
"{h}{}{r}: {}",
diag.severity().as_str(),
diag.message(),
h = palette.header,
r = palette.reset
)?;
let mut located: Vec<(SourceId, bool, &Label)> = Vec::new();
let mut unlocated: Vec<&Label> = Vec::new();
for (is_primary, label) in labels(diag) {
match map.locate(label.span().start()) {
Some((id, _)) => located.push((id, is_primary, label)),
None => unlocated.push(label),
}
}
for id in frame_order(&located) {
write_frame(out, map, id, &located, &palette)?;
}
for label in &unlocated {
writeln!(
out,
" = {n}note:{r} span {} is outside the loaded sources",
label.span(),
n = palette.note,
r = palette.reset
)?;
}
for note in diag.notes() {
writeln!(
out,
" = {n}note:{r} {note}",
n = palette.note,
r = palette.reset
)?;
}
for help in diag.help() {
writeln!(
out,
" = {h}help:{r} {help}",
h = palette.help,
r = palette.reset
)?;
}
Ok(())
}
}
fn labels(diag: &Diagnostic) -> impl Iterator<Item = (bool, &Label)> {
core::iter::once((true, diag.primary())).chain(diag.secondary().iter().map(|l| (false, l)))
}
fn frame_order(located: &[(SourceId, bool, &Label)]) -> Vec<SourceId> {
let primary_file = located.iter().find(|(_, p, _)| *p).map(|(id, _, _)| *id);
let mut ids: Vec<SourceId> = located.iter().map(|(id, _, _)| *id).collect();
ids.sort_unstable_by_key(|id| id.to_u32());
ids.dedup();
if let Some(pf) = primary_file {
ids.retain(|id| *id != pf);
ids.insert(0, pf);
}
ids
}
struct Mark<'a> {
line: u32,
lead: usize,
width: usize,
marker: char,
message: Option<&'a str>,
primary: bool,
}
fn write_frame(
out: &mut impl fmt::Write,
map: &SourceMap,
id: SourceId,
located: &[(SourceId, bool, &Label)],
palette: &Palette,
) -> fmt::Result {
let Some(file) = map.source(id) else {
return Ok(());
};
let text = file.text();
let base = file.span().start().to_u32();
let index = file.line_index();
let mut marks: Vec<Mark<'_>> = Vec::new();
let mut anchor: Option<(u32, u32)> = None;
let mut anchor_is_primary = false;
for &(label_id, is_primary, label) in located {
if label_id != id {
continue;
}
let local_start = label.span().start().to_u32().saturating_sub(base);
let local_end = (label.span().end().to_u32().saturating_sub(base) as usize).min(text.len());
let lc = index.line_col(BytePos::new(local_start));
let here = (lc.line, lc.col);
let better = match anchor {
None => true,
Some(_) if is_primary && !anchor_is_primary => true,
Some(_) if !is_primary && anchor_is_primary => false,
Some(a) => here < a,
};
if better {
anchor = Some(here);
anchor_is_primary = is_primary;
}
push_marks(
&mut marks,
&index,
text,
local_start as usize,
local_end,
lc.line,
is_primary,
label.message(),
);
}
let Some((anchor_line, anchor_col)) = anchor else {
return Ok(());
};
let mut lines: Vec<u32> = marks.iter().map(|m| m.line).collect();
lines.sort_unstable();
lines.dedup();
let max_line = lines.last().copied().unwrap_or(anchor_line);
let gw = decimal_width(max_line);
let g = palette.gutter;
let r = palette.reset;
writeln!(
out,
"{:gw$}{g}-->{r} {}:{anchor_line}:{anchor_col}",
"",
file.name()
)?;
writeln!(out, "{:gw$} {g}|{r}", "")?;
for &line in &lines {
let line_text = index
.line_span(line)
.and_then(|span| text.get(span.start().to_usize()..span.end().to_usize()))
.unwrap_or("");
let mut expanded = String::new();
expand_tabs(line_text, &mut expanded);
writeln!(out, "{g}{line:>gw$}{r} {g}|{r} {expanded}")?;
let mut row: Vec<&Mark<'_>> = marks.iter().filter(|m| m.line == line).collect();
row.sort_by(|a, b| a.lead.cmp(&b.lead).then_with(|| b.primary.cmp(&a.primary)));
for mark in row {
let mc = palette.marker(mark.primary);
write!(out, "{:gw$} {g}|{r} {:lead$}{mc}", "", "", lead = mark.lead)?;
for _ in 0..mark.width {
out.write_char(mark.marker)?;
}
match mark.message {
Some(message) => writeln!(out, " {message}{r}")?,
None => writeln!(out, "{r}")?,
}
}
}
writeln!(out, "{:gw$} {g}|{r}", "")
}
#[allow(clippy::too_many_arguments)]
fn push_marks<'a>(
marks: &mut Vec<Mark<'a>>,
index: &span_lang::LineIndex<'_>,
text: &str,
local_start: usize,
local_end: usize,
start_line: u32,
primary: bool,
message: &'a str,
) {
let marker = if primary { '^' } else { '-' };
let end_line = if local_end > local_start {
index.line_col(BytePos::new((local_end - 1) as u32)).line
} else {
start_line
};
for line in start_line..=end_line {
let Some(span) = index.line_span(line) else {
continue;
};
let lo = span.start().to_usize();
let hi = span.end().to_usize();
let Some(line_text) = text.get(lo..hi) else {
continue;
};
let seg_start = local_start.max(lo);
let seg_end = local_end.min(hi).max(seg_start);
let a = floor_boundary(line_text, seg_start - lo);
let b = floor_boundary(line_text, seg_end - lo).max(a);
let lead = visual_width(&line_text[..a]);
let width = visual_width(&line_text[..b]).saturating_sub(lead).max(1);
let is_last = line == end_line;
marks.push(Mark {
line,
lead,
width,
marker,
message: (is_last && !message.is_empty()).then_some(message),
primary,
});
}
}
const fn decimal_width(mut n: u32) -> usize {
let mut width = 1;
while n >= 10 {
n /= 10;
width += 1;
}
width
}
fn floor_boundary(s: &str, idx: usize) -> usize {
let mut at = idx.min(s.len());
while at > 0 && !s.is_char_boundary(at) {
at -= 1;
}
at
}
fn visual_width(s: &str) -> usize {
let mut col = 0;
for ch in s.chars() {
if ch == '\t' {
col += TAB_WIDTH - (col % TAB_WIDTH);
} else {
col += 1;
}
}
col
}
fn expand_tabs(s: &str, out: &mut String) {
let mut col = 0;
for ch in s.chars() {
if ch == '\t' {
let advance = TAB_WIDTH - (col % TAB_WIDTH);
for _ in 0..advance {
out.push(' ');
}
col += advance;
} else {
out.push(ch);
col += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decimal_width_counts_digits() {
assert_eq!(decimal_width(0), 1);
assert_eq!(decimal_width(9), 1);
assert_eq!(decimal_width(10), 2);
assert_eq!(decimal_width(999), 3);
assert_eq!(decimal_width(1000), 4);
}
#[test]
fn test_visual_width_counts_chars_as_one() {
assert_eq!(visual_width("αβ"), 2);
assert_eq!(visual_width("abc"), 3);
}
#[test]
fn test_visual_width_expands_tabs_to_stops() {
assert_eq!(visual_width("\t"), 4);
assert_eq!(visual_width("a\t"), 4);
assert_eq!(visual_width("abcd\t"), 8);
assert_eq!(visual_width("\t\t"), 8);
}
#[test]
fn test_expand_tabs_matches_visual_width() {
for s in ["", "a", "\t", "a\tb", "\tx\t", "abcd\te"] {
let mut out = String::new();
expand_tabs(s, &mut out);
assert_eq!(out.chars().count(), visual_width(s), "for {s:?}");
assert!(!out.contains('\t'));
}
}
#[test]
fn test_floor_boundary_moves_into_char_start() {
assert_eq!(floor_boundary("αβ", 1), 0);
assert_eq!(floor_boundary("αβ", 2), 2);
assert_eq!(floor_boundary("ab", 9), 2);
}
}