Skip to main content

molframe_core/diagnostic/
render.rs

1//! Presentation of findings.
2//!
3//! There is exactly one renderer. The command line, a Rust `Display` and any
4//! binding all route through it, so a finding reads the same wherever it
5//! surfaces and a user searching for the text of an error finds one answer.
6//!
7//! Rendering borrows everything it needs and writes straight to the formatter,
8//! so showing a finding costs no allocation even when it quotes the source line.
9
10use super::{Diagnostic, Severity};
11use std::fmt;
12
13const RESET: &str = "\u{1b}[0m";
14const BOLD: &str = "\u{1b}[1m";
15const RED: &str = "\u{1b}[31m";
16const YELLOW: &str = "\u{1b}[33m";
17const BLUE: &str = "\u{1b}[34m";
18
19/// A finding prepared for display.
20///
21/// Built with [`Diagnostic::render`](Diagnostic) style helpers on this type and
22/// then formatted. Without a source buffer it renders the heading and the
23/// labelled details; with one it also quotes and underlines the offending line.
24///
25/// # Examples
26///
27/// ```
28/// use molframe_core::{Code, Diagnostic, Rendered};
29///
30/// let finding = Diagnostic::new(Code::E1106);
31/// let text = Rendered::new(&finding).to_string();
32/// assert!(text.starts_with("error[MOLFRAME-E1106]"));
33/// ```
34#[derive(Clone, Copy, Debug)]
35pub struct Rendered<'a> {
36    finding: &'a Diagnostic,
37    source: Option<&'a [u8]>,
38    origin: Option<&'a str>,
39    color: bool,
40}
41
42impl<'a> Rendered<'a> {
43    /// Prepares a finding for display with no source context and no colour.
44    #[must_use]
45    pub const fn new(finding: &'a Diagnostic) -> Self {
46        Self {
47            finding,
48            source: None,
49            origin: None,
50            color: false,
51        }
52    }
53
54    /// Supplies the bytes the finding was raised against, so the offending line
55    /// can be quoted.
56    #[must_use]
57    pub const fn with_source(mut self, source: &'a [u8]) -> Self {
58        self.source = Some(source);
59        self
60    }
61
62    /// Names where the input came from, for the location line.
63    #[must_use]
64    pub const fn with_origin(mut self, origin: &'a str) -> Self {
65        self.origin = Some(origin);
66        self
67    }
68
69    /// Enables ANSI colour.
70    ///
71    /// Callers decide this from whether the stream is a terminal and from the
72    /// user's own preference; the renderer does not probe the environment.
73    #[must_use]
74    pub const fn with_color(mut self, color: bool) -> Self {
75        self.color = color;
76        self
77    }
78
79    fn accent(self) -> &'static str {
80        if !self.color {
81            return "";
82        }
83        match self.finding.severity() {
84            Severity::Invalidating | Severity::Breaking => RED,
85            Severity::Strict | Severity::Loose => YELLOW,
86            Severity::Info => BLUE,
87        }
88    }
89
90    fn paint(self, code: &'static str) -> &'static str {
91        if self.color { code } else { "" }
92    }
93
94    /// Writes the `error[CODE]: message` heading.
95    fn write_heading(self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        let finding = self.finding;
97        write!(
98            f,
99            "{}{}{}[{}]{}: {}{}{}",
100            self.accent(),
101            self.paint(BOLD),
102            finding.severity().label(),
103            finding.code(),
104            self.paint(RESET),
105            self.paint(BOLD),
106            finding.message(),
107            self.paint(RESET),
108        )
109    }
110
111    /// Writes the ` ┌─ origin:line:column` locator.
112    fn write_location(self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        let (Some(span), gutter) = (self.finding.span(), self.paint(BLUE)) else {
114            if let Some(origin) = self.origin {
115                write!(
116                    f,
117                    "\n  {}┌─{} {origin}",
118                    self.paint(BLUE),
119                    self.paint(RESET)
120                )?;
121            }
122            return Ok(());
123        };
124        write!(f, "\n  {gutter}┌─{} ", self.paint(RESET))?;
125        match self.origin {
126            Some(origin) => write!(f, "{origin}:{}", span.start),
127            None => write!(f, "{}", span.start),
128        }
129    }
130
131    /// Quotes the source line the span falls on and underlines the span.
132    fn write_snippet(self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        let (Some(source), Some(span)) = (self.source, self.finding.span()) else {
134            return Ok(());
135        };
136        let Ok(offset) = usize::try_from(span.start.byte_offset) else {
137            return Ok(());
138        };
139        let Some(line) = line_containing(source, offset) else {
140            return Ok(());
141        };
142        let Ok(text) = str::from_utf8(line.text) else {
143            return Ok(());
144        };
145
146        let gutter = self.paint(BLUE);
147        let reset = self.paint(RESET);
148        let number = span.start.line;
149        let width = decimal_width(number);
150
151        write!(f, "\n {:width$} {gutter}│{reset}", "")?;
152        write!(f, "\n {number} {gutter}│{reset} {}", text.trim_end())?;
153
154        let column = offset.checked_sub(line.start).ok_or(fmt::Error)?;
155        let available = text.len().checked_sub(column).ok_or(fmt::Error)?.max(1);
156        let caret_count = span
157            .len()
158            .and_then(|length| usize::try_from(length).ok())
159            .ok_or(fmt::Error)?
160            .clamp(1, available);
161        write!(f, "\n {:width$} {gutter}│{reset} {:column$}", "", "")?;
162        write!(f, "{}", self.accent())?;
163        for _ in 0..caret_count {
164            f.write_str("^")?;
165        }
166        f.write_str(reset)
167    }
168
169    /// Writes the labelled details and the remedy.
170    fn write_details(self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        let finding = self.finding;
172        let gutter = self.paint(BLUE);
173        let reset = self.paint(RESET);
174
175        if let Some(category) = finding.category() {
176            write!(f, "\n  {gutter}={reset} category: {category}")?;
177        }
178        if let Some(field) = finding.field() {
179            write!(f, "\n  {gutter}={reset} item: {field}")?;
180        }
181        if let Some(row) = finding.row() {
182            write!(f, "\n  {gutter}={reset} row: {row}")?;
183        }
184        for item in finding.context() {
185            write!(f, "\n  {gutter}={reset} {}: {}", item.label(), item.value())?;
186        }
187        write!(f, "\n  {gutter}={reset} help: {}", finding.remedy())
188    }
189}
190
191impl fmt::Display for Rendered<'_> {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        self.write_heading(f)?;
194        self.write_location(f)?;
195        self.write_snippet(f)?;
196        self.write_details(f)
197    }
198}
199
200/// The bounds of the line containing a byte offset.
201struct Line<'a> {
202    text: &'a [u8],
203    start: usize,
204}
205
206fn line_containing(source: &[u8], offset: usize) -> Option<Line<'_>> {
207    if offset > source.len() {
208        return None;
209    }
210    let start = source[..offset]
211        .iter()
212        .rposition(|&b| b == b'\n')
213        .map_or(0, |at| at + 1);
214    let end = source[start..]
215        .iter()
216        .position(|&b| b == b'\n')
217        .map_or(source.len(), |at| start + at);
218    Some(Line {
219        text: source.get(start..end)?,
220        start,
221    })
222}
223
224const fn decimal_width(mut value: u64) -> usize {
225    let mut width = 1;
226    while value >= 10 {
227        value /= 10;
228        width += 1;
229    }
230    width
231}
232
233#[cfg(test)]
234#[path = "render_tests.rs"]
235mod tests;