Skip to main content

miette/handlers/graphical/
report.rs

1//! Diagnostic-level rendering: everything except the source snippets.
2//!
3//! [`render_report`](GraphicalReportHandler::render_report) is the entry point.
4//! It renders the title, hands off to
5//! [`render_snippets`](GraphicalReportHandler::render_snippets), then renders
6//! the help/note footer. Each block of prose is wrapped to the terminal width
7//! using the shared [`wrap_options`](GraphicalReportHandler::wrap_options)
8//! helper.
9
10use std::fmt;
11
12use owo_colors::OwoColorize;
13
14use super::handler::{GraphicalReportHandler, LinkStyle};
15use crate::{Diagnostic, Severity};
16
17impl GraphicalReportHandler {
18    /// Render a [`Diagnostic`].
19    ///
20    /// # Errors
21    ///
22    /// Returns an error when writing the rendered report fails.
23    pub fn render_report(
24        &self,
25        f: &mut impl fmt::Write,
26        diagnostic: &dyn Diagnostic,
27    ) -> fmt::Result {
28        writeln!(f)?;
29        self.render_title(f, diagnostic)?;
30        let src = diagnostic.source_code();
31        self.render_snippets(f, diagnostic, src)?;
32        self.render_footer(f, diagnostic)?;
33        Ok(())
34    }
35
36    fn render_title(&self, f: &mut impl fmt::Write, diagnostic: &dyn Diagnostic) -> fmt::Result {
37        let (severity_style, severity_icon) = match diagnostic.severity() {
38            Some(Severity::Error) | None => (self.theme.styles.error, &self.theme.characters.error),
39            Some(Severity::Warning) => (self.theme.styles.warning, &self.theme.characters.warning),
40            Some(Severity::Advice) => (self.theme.styles.advice, &self.theme.characters.advice),
41        };
42
43        let width = self.termwidth.saturating_sub(2);
44
45        let title = match (self.links, diagnostic.url(), diagnostic.code()) {
46            (LinkStyle::Link, Some(url), Some(code)) => {
47                // magic unicode escape sequences to make the terminal print a hyperlink
48                const CTL: &str = "\u{1b}]8;;";
49                const END: &str = "\u{1b}]8;;\u{1b}\\";
50                let code = code.style(severity_style);
51                let title = diagnostic.style(severity_style);
52                format!("{CTL}{url}\u{1b}\\{code}{END}: {title}")
53            }
54            (_, _, Some(code)) if severity_style.is_plain() => format!("{code}: {diagnostic}"),
55            (_, _, Some(code)) => {
56                format!("{}", format_args!("{code}: {diagnostic}").style(severity_style))
57            }
58            _ if severity_style.is_plain() => diagnostic.to_string(),
59            _ => format!("{}", diagnostic.style(severity_style)),
60        };
61        if !title.contains('\n')
62            && severity_icon.len().saturating_add(title.len()).saturating_add(3) <= width
63        {
64            f.write_str("  ")?;
65            if severity_style.is_plain() {
66                f.write_str(severity_icon)?;
67            } else {
68                write!(f, "{}", severity_icon.style(severity_style))?;
69            }
70            f.write_char(' ')?;
71            f.write_str(title.trim_end_matches(' '))?;
72        } else {
73            // No-color themes can bypass owo-colors' formatting machinery entirely.
74            let (initial_indent, rest_indent) = if severity_style.is_plain() {
75                (format!("  {severity_icon} "), format!("  {} ", self.theme.characters.vbar))
76            } else {
77                (
78                    format!("  {} ", severity_icon.style(severity_style)),
79                    format!("  {} ", self.theme.characters.vbar.style(severity_style)),
80                )
81            };
82            let opts = Self::wrap_options(width, &initial_indent, &rest_indent);
83            Self::write_fill(f, &title, opts)?;
84        }
85        f.write_char('\n')?;
86
87        Ok(())
88    }
89
90    fn render_footer(&self, f: &mut impl fmt::Write, diagnostic: &dyn Diagnostic) -> fmt::Result {
91        if let Some(help) = diagnostic.help() {
92            const PREFIX: &str = "  help: ";
93            let width = self.termwidth.saturating_sub(4);
94            if memchr::memchr(b'\n', help.as_bytes()).is_none()
95                && PREFIX.len().saturating_add(help.len()) <= width
96            {
97                if self.theme.styles.help.is_plain() {
98                    f.write_str(PREFIX)?;
99                } else {
100                    write!(f, "{}", PREFIX.style(self.theme.styles.help))?;
101                }
102                f.write_str(help.trim_end_matches(' '))?;
103            } else {
104                let initial_indent = PREFIX.style(self.theme.styles.help).to_string();
105                let opts = Self::wrap_options(width, &initial_indent, "        ");
106                Self::write_fill(f, &help, opts)?;
107            }
108            f.write_char('\n')?;
109        }
110        if let Some(note) = diagnostic.note() {
111            // Renders as:
112            //   note: This is a note about the error
113            let width = self.termwidth.saturating_sub(4);
114            let initial_indent = "  note: ".style(self.theme.styles.note).to_string();
115            let opts = Self::wrap_options(width, &initial_indent, "           ");
116            Self::write_fill(f, &note, opts)?;
117            f.write_char('\n')?;
118        }
119        Ok(())
120    }
121
122    /// Builds the [`textwrap::Options`] shared by every wrapped block.
123    fn wrap_options<'a>(
124        width: usize,
125        initial_indent: &'a str,
126        subsequent_indent: &'a str,
127    ) -> textwrap::Options<'a> {
128        textwrap::Options::new(width)
129            .initial_indent(initial_indent)
130            .subsequent_indent(subsequent_indent)
131    }
132
133    fn write_fill(f: &mut impl fmt::Write, text: &str, opts: textwrap::Options<'_>) -> fmt::Result {
134        if Self::fits_on_line(text, &opts) {
135            f.write_str(opts.initial_indent)?;
136            f.write_str(text.trim_end_matches(' '))
137        } else {
138            f.write_str(&textwrap::fill(text, opts))
139        }
140    }
141
142    /// Skip word separation and optimal-fit layout when the text demonstrably
143    /// fits on its first line. `textwrap` only provides this fast path without
144    /// indentation, while every diagnostic block has an initial indent.
145    #[cfg(test)]
146    fn fill(text: &str, opts: textwrap::Options<'_>) -> String {
147        if Self::fits_on_line(text, &opts) {
148            let text = text.trim_end_matches(' ');
149            let mut result = String::with_capacity(opts.initial_indent.len() + text.len());
150            result.push_str(opts.initial_indent);
151            result.push_str(text);
152            return result;
153        }
154        textwrap::fill(text, opts)
155    }
156
157    fn fits_on_line(text: &str, opts: &textwrap::Options<'_>) -> bool {
158        if memchr::memchr(b'\n', text.as_bytes()).is_some() {
159            return false;
160        }
161
162        // UTF-8 byte length is an upper bound on terminal display width,
163        // including for ANSI escape sequences. Avoid both width scans when
164        // even that conservative bound fits.
165        opts.initial_indent.len().saturating_add(text.len()) <= opts.width || {
166            let available = opts.width.saturating_sub(Self::display_width(opts.initial_indent));
167            Self::display_width(text) <= available
168        }
169    }
170
171    /// Compute terminal width bytewise for ASCII, including the CSI and OSC
172    /// escape sequences recognized by `textwrap`. Unicode retains its full
173    /// width calculation.
174    fn display_width(text: &str) -> usize {
175        if !text.is_ascii() {
176            return textwrap::core::display_width(text);
177        }
178
179        let bytes = text.as_bytes();
180        let mut width = 0;
181        let mut i = 0;
182        while i < bytes.len() {
183            if bytes[i] != b'\x1b' {
184                width += usize::from((b' '..=b'~').contains(&bytes[i]));
185                i += 1;
186                continue;
187            }
188
189            i += 1;
190            let Some(&kind) = bytes.get(i) else { break };
191            i += 1;
192            match kind {
193                b'[' => {
194                    while i < bytes.len() {
195                        let byte = bytes[i];
196                        i += 1;
197                        if (b'@'..=b'~').contains(&byte) {
198                            break;
199                        }
200                    }
201                }
202                b']' => {
203                    while i < bytes.len() {
204                        if bytes[i] == b'\x07' {
205                            i += 1;
206                            break;
207                        }
208                        if bytes[i] == b'\x1b' && bytes.get(i + 1) == Some(&b'\\') {
209                            i += 2;
210                            break;
211                        }
212                        i += 1;
213                    }
214                }
215                _ => {}
216            }
217        }
218        width
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    #[cfg_attr(
228        miri,
229        ignore = "exhaustive equivalence check over safe text wrapping code; interpreting every \
230                  textwrap case under Miri takes more than 16 minutes"
231    )]
232    fn fill_fast_path_matches_textwrap() {
233        let texts = [
234            "",
235            "short diagnostic",
236            "trailing spaces   ",
237            "  leading spaces",
238            "two  inner  spaces",
239            "Café 火",
240            "combining e\u{301}",
241            "emoji 🐂",
242            "\u{1b}[31mstyled text\u{1b}[0m",
243            "\u{1b}]8;;https://example.com\u{1b}\\linked\u{1b}]8;;\u{1b}\\",
244            "\u{1b}]0;title\u{7}visible",
245            "control\tcharacters\u{7}",
246            "incomplete \u{1b}[31",
247            "first\nsecond",
248        ];
249        for width in 0..32 {
250            for initial_indent in ["", "  ", "  help: ", "\u{1b}[31m  × \u{1b}[0m"] {
251                for text in texts {
252                    let opts = textwrap::Options::new(width)
253                        .initial_indent(initial_indent)
254                        .subsequent_indent("    ");
255                    assert_eq!(
256                        GraphicalReportHandler::fill(text, opts.clone()),
257                        textwrap::fill(text, opts.clone()),
258                        "width={width}, indent={initial_indent:?}, text={text:?}"
259                    );
260                    let mut output = String::new();
261                    GraphicalReportHandler::write_fill(&mut output, text, opts.clone()).unwrap();
262                    assert_eq!(
263                        output,
264                        textwrap::fill(text, opts),
265                        "streaming: width={width}, indent={initial_indent:?}, text={text:?}"
266                    );
267                }
268            }
269        }
270    }
271}